diff options
author | Alessio Caiazza <acaiazza@gitlab.com> | 2018-07-09 09:40:35 +0200 |
---|---|---|
committer | Alessio Caiazza <acaiazza@gitlab.com> | 2018-07-09 09:40:35 +0200 |
commit | 6daf7092294afa32052a7d310909a99dbf763c59 (patch) | |
tree | 15064f5694f806eaff05a967b50fee98e1c75f12 | |
parent | c9a5f717abd0b57e4a77fdd0dc9eaa0646bf895e (diff) | |
parent | 96eb6fd33b5dfc4910d8bd93e697d6b6eb70b991 (diff) | |
download | gitlab-ce-6daf7092294afa32052a7d310909a99dbf763c59.tar.gz |
Merge commit '96eb6fd33b5dfc4910d8bd93e697d6b6eb70b991' into 11-1-stable-prepare-rc6
261 files changed, 5939 insertions, 2831 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 068bced3785..5af665a17e0 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.110.0 +0.111.0 diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index f374f6662e9..3eefcb9dd5b 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -0.9.1 +1.0.0 @@ -418,7 +418,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 0.103.0', require: 'gitaly' +gem 'gitaly-proto', '~> 0.105.0', require: 'gitaly' gem 'grpc', '~> 1.11.0' # Locked until https://github.com/google/protobuf/issues/4210 is closed diff --git a/Gemfile.lock b/Gemfile.lock index 79682559522..a889c4dc3a0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -282,7 +282,7 @@ GEM gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) rails (>= 3.2.0) - gitaly-proto (0.103.0) + gitaly-proto (0.105.0) google-protobuf (~> 3.1) grpc (~> 1.10) github-linguist (5.3.3) @@ -1037,7 +1037,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 0.103.0) + gitaly-proto (~> 0.105.0) github-linguist (~> 5.3.3) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-gollum-lib (~> 4.2) diff --git a/Gemfile.rails5.lock b/Gemfile.rails5.lock index 0d153a526e7..8c46b8c5916 100644 --- a/Gemfile.rails5.lock +++ b/Gemfile.rails5.lock @@ -285,7 +285,7 @@ GEM gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) rails (>= 3.2.0) - gitaly-proto (0.103.0) + gitaly-proto (0.105.0) google-protobuf (~> 3.1) grpc (~> 1.10) github-linguist (5.3.3) @@ -1047,7 +1047,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 0.103.0) + gitaly-proto (~> 0.105.0) github-linguist (~> 5.3.3) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-gollum-lib (~> 4.2) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 0ca0e8f35dd..422becb7db8 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -100,12 +100,12 @@ const Api = { }, // Return Merge Request for project - mergeRequest(projectPath, mergeRequestId) { + mergeRequest(projectPath, mergeRequestId, params = {}) { const url = Api.buildUrl(Api.mergeRequestPath) .replace(':id', encodeURIComponent(projectPath)) .replace(':mrid', mergeRequestId); - return axios.get(url); + return axios.get(url, { params }); }, mergeRequests(params = {}) { diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index b884230fb63..83569346cf8 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -20,16 +20,13 @@ export default { }, }, computed: { - ...mapGetters(['commit']), + ...mapGetters(['commitId']), normalizedDiffLines() { return this.diffLines.map(line => (line.richText ? trimFirstCharOfLineContent(line) : line)); }, diffLinesLength() { return this.normalizedDiffLines.length; }, - commitId() { - return this.commit && this.commit.id; - }, userColorScheme() { return window.gon.user_color_scheme; }, diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 52561e197e6..89148eb5e18 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -21,7 +21,7 @@ export default { }, }, computed: { - ...mapGetters(['commit']), + ...mapGetters(['commitId']), parallelDiffLines() { return this.diffLines.map(line => { const parallelLine = Object.assign({}, line); @@ -44,9 +44,6 @@ export default { diffLinesLength() { return this.parallelDiffLines.length; }, - commitId() { - return this.commit && this.commit.id; - }, userColorScheme() { return window.gon.user_color_scheme; }, diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 66d0f47d102..f3c2d7427e7 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -1,16 +1,12 @@ import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants'; -export default { - isParallelView(state) { - return state.diffViewType === PARALLEL_DIFF_VIEW_TYPE; - }, - isInlineView(state) { - return state.diffViewType === INLINE_DIFF_VIEW_TYPE; - }, - areAllFilesCollapsed(state) { - return state.diffFiles.every(file => file.collapsed); - }, - commit(state) { - return state.commit; - }, -}; +export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW_TYPE; + +export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE; + +export const areAllFilesCollapsed = state => state.diffFiles.every(file => file.collapsed); + +export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js new file mode 100644 index 00000000000..39d90a64aab --- /dev/null +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -0,0 +1,18 @@ +import Cookies from 'js-cookie'; +import { getParameterValues } from '~/lib/utils/url_utility'; +import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; + +const viewTypeFromQueryString = getParameterValues('view')[0]; +const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); +const defaultViewType = INLINE_DIFF_VIEW_TYPE; + +export default () => ({ + isLoading: true, + endpoint: '', + basePath: '', + commit: null, + diffFiles: [], + mergeRequestDiffs: [], + diffLineCommentForms: {}, + diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, +}); diff --git a/app/assets/javascripts/diffs/store/modules/index.js b/app/assets/javascripts/diffs/store/modules/index.js index 94caa131506..c745320d532 100644 --- a/app/assets/javascripts/diffs/store/modules/index.js +++ b/app/assets/javascripts/diffs/store/modules/index.js @@ -1,25 +1,10 @@ -import Cookies from 'js-cookie'; -import { getParameterValues } from '~/lib/utils/url_utility'; import actions from '../actions'; -import getters from '../getters'; +import * as getters from '../getters'; import mutations from '../mutations'; -import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; - -const viewTypeFromQueryString = getParameterValues('view')[0]; -const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); -const defaultViewType = INLINE_DIFF_VIEW_TYPE; +import createState from './diff_state'; export default { - state: { - isLoading: true, - endpoint: '', - basePath: '', - commit: null, - diffFiles: [], - mergeRequestDiffs: [], - diffLineCommentForms: {}, - diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, - }, + state: createState(), getters, actions, mutations, diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 8aa8a114c6f..a98b2be89a3 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -66,15 +66,10 @@ export default { }, [types.EXPAND_ALL_FILES](state) { - const diffFiles = []; - - state.diffFiles.forEach(file => { - diffFiles.push({ - ...file, - collapsed: false, - }); - }); - - Object.assign(state, { diffFiles }); + // eslint-disable-next-line no-param-reassign + state.diffFiles = state.diffFiles.map(file => ({ + ...file, + collapsed: false, + })); }, }; diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue new file mode 100644 index 00000000000..2f030de8967 --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -0,0 +1,122 @@ +<script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import AccessorUtilities from '~/lib/utils/accessor'; +import eventHub from '../event_hub'; +import store from '../store/'; +import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants'; +import { isMobile, updateExistingFrequentItem } from '../utils'; +import FrequentItemsSearchInput from './frequent_items_search_input.vue'; +import FrequentItemsList from './frequent_items_list.vue'; +import frequentItemsMixin from './frequent_items_mixin'; + +export default { + store, + components: { + LoadingIcon, + FrequentItemsSearchInput, + FrequentItemsList, + }, + mixins: [frequentItemsMixin], + props: { + currentUserName: { + type: String, + required: true, + }, + currentItem: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']), + ...mapGetters(['hasSearchQuery']), + translations() { + return this.getTranslations(['loadingMessage', 'header']); + }, + }, + created() { + const { namespace, currentUserName, currentItem } = this; + const storageKey = `${currentUserName}/${STORAGE_KEY[namespace]}`; + + this.setNamespace(namespace); + this.setStorageKey(storageKey); + + if (currentItem.id) { + this.logItemAccess(storageKey, currentItem); + } + + eventHub.$on(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); + }, + beforeDestroy() { + eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); + }, + methods: { + ...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']), + dropdownOpenHandler() { + if (this.searchQuery === '' || isMobile()) { + this.fetchFrequentItems(); + } + }, + logItemAccess(storageKey, item) { + if (!AccessorUtilities.isLocalStorageAccessSafe()) { + return false; + } + + // Check if there's any frequent items list set + const storedRawItems = localStorage.getItem(storageKey); + const storedFrequentItems = storedRawItems + ? JSON.parse(storedRawItems) + : [{ ...item, frequency: 1 }]; // No frequent items list set, set one up. + + // Check if item already exists in list + const itemMatchIndex = storedFrequentItems.findIndex( + frequentItem => frequentItem.id === item.id, + ); + + if (itemMatchIndex > -1) { + storedFrequentItems[itemMatchIndex] = updateExistingFrequentItem( + storedFrequentItems[itemMatchIndex], + item, + ); + } else { + if (storedFrequentItems.length === FREQUENT_ITEMS.MAX_COUNT) { + storedFrequentItems.shift(); + } + + storedFrequentItems.push({ ...item, frequency: 1 }); + } + + return localStorage.setItem(storageKey, JSON.stringify(storedFrequentItems)); + }, + }, +}; +</script> + +<template> + <div> + <frequent-items-search-input + :namespace="namespace" + /> + <loading-icon + v-if="isLoadingItems" + :label="translations.loadingMessage" + class="loading-animation prepend-top-20" + size="2" + /> + <div + v-if="!isLoadingItems && !hasSearchQuery" + class="section-header" + > + {{ translations.header }} + </div> + <frequent-items-list + v-if="!isLoadingItems" + :items="items" + :namespace="namespace" + :has-search-query="hasSearchQuery" + :is-fetch-failed="isFetchFailed" + :matcher="searchQuery" + /> + </div> +</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue new file mode 100644 index 00000000000..8e511aa2a36 --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue @@ -0,0 +1,78 @@ +<script> +import FrequentItemsListItem from './frequent_items_list_item.vue'; +import frequentItemsMixin from './frequent_items_mixin'; + +export default { + components: { + FrequentItemsListItem, + }, + mixins: [frequentItemsMixin], + props: { + items: { + type: Array, + required: true, + }, + hasSearchQuery: { + type: Boolean, + required: true, + }, + isFetchFailed: { + type: Boolean, + required: true, + }, + matcher: { + type: String, + required: true, + }, + }, + computed: { + translations() { + return this.getTranslations([ + 'itemListEmptyMessage', + 'itemListErrorMessage', + 'searchListEmptyMessage', + 'searchListErrorMessage', + ]); + }, + isListEmpty() { + return this.items.length === 0; + }, + listEmptyMessage() { + if (this.hasSearchQuery) { + return this.isFetchFailed + ? this.translations.searchListErrorMessage + : this.translations.searchListEmptyMessage; + } + + return this.isFetchFailed + ? this.translations.itemListErrorMessage + : this.translations.itemListEmptyMessage; + }, + }, +}; +</script> + +<template> + <div class="frequent-items-list-container"> + <ul class="list-unstyled"> + <li + v-if="isListEmpty" + :class="{ 'section-failure': isFetchFailed }" + class="section-empty" + > + {{ listEmptyMessage }} + </li> + <frequent-items-list-item + v-for="item in items" + v-else + :key="item.id" + :item-id="item.id" + :item-name="item.name" + :namespace="item.namespace" + :web-url="item.webUrl" + :avatar-url="item.avatarUrl" + :matcher="matcher" + /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue new file mode 100644 index 00000000000..1f1665ff7fe --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -0,0 +1,117 @@ +<script> +/* eslint-disable vue/require-default-prop, vue/require-prop-types */ +import Identicon from '../../vue_shared/components/identicon.vue'; + +export default { + components: { + Identicon, + }, + props: { + matcher: { + type: String, + required: false, + }, + itemId: { + type: Number, + required: true, + }, + itemName: { + type: String, + required: true, + }, + namespace: { + type: String, + required: false, + }, + webUrl: { + type: String, + required: true, + }, + avatarUrl: { + required: true, + validator(value) { + return value === null || typeof value === 'string'; + }, + }, + }, + computed: { + hasAvatar() { + return this.avatarUrl !== null; + }, + highlightedItemName() { + if (this.matcher) { + const matcherRegEx = new RegExp(this.matcher, 'gi'); + const matches = this.itemName.match(matcherRegEx); + + if (matches && matches.length > 0) { + return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`); + } + } + return this.itemName; + }, + /** + * Smartly truncates item namespace by doing two things; + * 1. Only include Group names in path by removing item name + * 2. Only include first and last group names in the path + * when namespace has more than 2 groups present + * + * First part (removal of item name from namespace) can be + * done from backend but doing so involves migration of + * existing item namespaces which is not wise thing to do. + */ + truncatedNamespace() { + if (!this.namespace) { + return null; + } + const namespaceArr = this.namespace.split(' / '); + + namespaceArr.splice(-1, 1); + let namespace = namespaceArr.join(' / '); + + if (namespaceArr.length > 2) { + namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`; + } + + return namespace; + }, + }, +}; +</script> + +<template> + <li class="frequent-items-list-item-container"> + <a + :href="webUrl" + class="clearfix" + > + <div class="frequent-items-item-avatar-container"> + <img + v-if="hasAvatar" + :src="avatarUrl" + class="avatar s32" + /> + <identicon + v-else + :entity-id="itemId" + :entity-name="itemName" + size-class="s32" + /> + </div> + <div class="frequent-items-item-metadata-container"> + <div + :title="itemName" + class="frequent-items-item-title" + v-html="highlightedItemName" + > + </div> + <div + v-if="truncatedNamespace" + :title="namespace" + class="frequent-items-item-namespace" + > + {{ truncatedNamespace }} + </div> + </div> + </a> + </li> +</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js b/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js new file mode 100644 index 00000000000..704dc83ca8e --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js @@ -0,0 +1,23 @@ +import { TRANSLATION_KEYS } from '../constants'; + +export default { + props: { + namespace: { + type: String, + required: true, + }, + }, + methods: { + getTranslations(keys) { + const translationStrings = keys.reduce( + (acc, key) => ({ + ...acc, + [key]: TRANSLATION_KEYS[this.namespace][key], + }), + {}, + ); + + return translationStrings; + }, + }, +}; diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue new file mode 100644 index 00000000000..a6a265eb3fd --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue @@ -0,0 +1,55 @@ +<script> +import _ from 'underscore'; +import { mapActions } from 'vuex'; +import eventHub from '../event_hub'; +import frequentItemsMixin from './frequent_items_mixin'; + +export default { + mixins: [frequentItemsMixin], + data() { + return { + searchQuery: '', + }; + }, + computed: { + translations() { + return this.getTranslations(['searchInputPlaceholder']); + }, + }, + watch: { + searchQuery: _.debounce(function debounceSearchQuery() { + this.setSearchQuery(this.searchQuery); + }, 500), + }, + mounted() { + eventHub.$on(`${this.namespace}-dropdownOpen`, this.setFocus); + }, + beforeDestroy() { + eventHub.$off(`${this.namespace}-dropdownOpen`, this.setFocus); + }, + methods: { + ...mapActions(['setSearchQuery']), + setFocus() { + this.$refs.search.focus(); + }, + }, +}; +</script> + +<template> + <div class="search-input-container d-none d-sm-block"> + <input + ref="search" + v-model="searchQuery" + :placeholder="translations.searchInputPlaceholder" + type="search" + class="form-control" + /> + <i + v-if="!searchQuery" + class="search-icon fa fa-fw fa-search" + aria-hidden="true" + > + </i> + </div> +</template> diff --git a/app/assets/javascripts/frequent_items/constants.js b/app/assets/javascripts/frequent_items/constants.js new file mode 100644 index 00000000000..9bc17f5ef4f --- /dev/null +++ b/app/assets/javascripts/frequent_items/constants.js @@ -0,0 +1,38 @@ +import { s__ } from '~/locale'; + +export const FREQUENT_ITEMS = { + MAX_COUNT: 20, + LIST_COUNT_DESKTOP: 5, + LIST_COUNT_MOBILE: 3, + ELIGIBLE_FREQUENCY: 3, +}; + +export const HOUR_IN_MS = 3600000; + +export const STORAGE_KEY = { + projects: 'frequent-projects', + groups: 'frequent-groups', +}; + +export const TRANSLATION_KEYS = { + projects: { + loadingMessage: s__('ProjectsDropdown|Loading projects'), + header: s__('ProjectsDropdown|Frequently visited'), + itemListErrorMessage: s__( + 'ProjectsDropdown|This feature requires browser localStorage support', + ), + itemListEmptyMessage: s__('ProjectsDropdown|Projects you visit often will appear here'), + searchListErrorMessage: s__('ProjectsDropdown|Something went wrong on our end.'), + searchListEmptyMessage: s__('ProjectsDropdown|Sorry, no projects matched your search'), + searchInputPlaceholder: s__('ProjectsDropdown|Search your projects'), + }, + groups: { + loadingMessage: s__('GroupsDropdown|Loading groups'), + header: s__('GroupsDropdown|Frequently visited'), + itemListErrorMessage: s__('GroupsDropdown|This feature requires browser localStorage support'), + itemListEmptyMessage: s__('GroupsDropdown|Groups you visit often will appear here'), + searchListErrorMessage: s__('GroupsDropdown|Something went wrong on our end.'), + searchListEmptyMessage: s__('GroupsDropdown|Sorry, no groups matched your search'), + searchInputPlaceholder: s__('GroupsDropdown|Search your groups'), + }, +}; diff --git a/app/assets/javascripts/projects_dropdown/event_hub.js b/app/assets/javascripts/frequent_items/event_hub.js index 0948c2e5352..0948c2e5352 100644 --- a/app/assets/javascripts/projects_dropdown/event_hub.js +++ b/app/assets/javascripts/frequent_items/event_hub.js diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js new file mode 100644 index 00000000000..5157ff211dc --- /dev/null +++ b/app/assets/javascripts/frequent_items/index.js @@ -0,0 +1,69 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import eventHub from '~/frequent_items/event_hub'; +import frequentItems from './components/app.vue'; + +Vue.use(Translate); + +const frequentItemDropdowns = [ + { + namespace: 'projects', + key: 'project', + }, + { + namespace: 'groups', + key: 'group', + }, +]; + +document.addEventListener('DOMContentLoaded', () => { + frequentItemDropdowns.forEach(dropdown => { + const { namespace, key } = dropdown; + const el = document.getElementById(`js-${namespace}-dropdown`); + const navEl = document.getElementById(`nav-${namespace}-dropdown`); + + // Don't do anything if element doesn't exist (No groups dropdown) + // This is for when the user accesses GitLab without logging in + if (!el || !navEl) { + return; + } + + $(navEl).on('shown.bs.dropdown', () => { + eventHub.$emit(`${namespace}-dropdownOpen`); + }); + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + frequentItems, + }, + data() { + const { dataset } = this.$options.el; + const item = { + id: Number(dataset[`${key}Id`]), + name: dataset[`${key}Name`], + namespace: dataset[`${key}Namespace`], + webUrl: dataset[`${key}WebUrl`], + avatarUrl: dataset[`${key}AvatarUrl`] || null, + lastAccessedOn: Date.now(), + }; + + return { + currentUserName: dataset.userName, + currentItem: item, + }; + }, + render(createElement) { + return createElement('frequent-items', { + props: { + namespace, + currentUserName: this.currentUserName, + currentItem: this.currentItem, + }, + }); + }, + }); + }); +}); diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js new file mode 100644 index 00000000000..3dd89a82a42 --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/actions.js @@ -0,0 +1,81 @@ +import Api from '~/api'; +import AccessorUtilities from '~/lib/utils/accessor'; +import * as types from './mutation_types'; +import { getTopFrequentItems } from '../utils'; + +export const setNamespace = ({ commit }, namespace) => { + commit(types.SET_NAMESPACE, namespace); +}; + +export const setStorageKey = ({ commit }, key) => { + commit(types.SET_STORAGE_KEY, key); +}; + +export const requestFrequentItems = ({ commit }) => { + commit(types.REQUEST_FREQUENT_ITEMS); +}; +export const receiveFrequentItemsSuccess = ({ commit }, data) => { + commit(types.RECEIVE_FREQUENT_ITEMS_SUCCESS, data); +}; +export const receiveFrequentItemsError = ({ commit }) => { + commit(types.RECEIVE_FREQUENT_ITEMS_ERROR); +}; + +export const fetchFrequentItems = ({ state, dispatch }) => { + dispatch('requestFrequentItems'); + + if (AccessorUtilities.isLocalStorageAccessSafe()) { + const storedFrequentItems = JSON.parse(localStorage.getItem(state.storageKey)); + + dispatch( + 'receiveFrequentItemsSuccess', + !storedFrequentItems ? [] : getTopFrequentItems(storedFrequentItems), + ); + } else { + dispatch('receiveFrequentItemsError'); + } +}; + +export const requestSearchedItems = ({ commit }) => { + commit(types.REQUEST_SEARCHED_ITEMS); +}; +export const receiveSearchedItemsSuccess = ({ commit }, data) => { + commit(types.RECEIVE_SEARCHED_ITEMS_SUCCESS, data); +}; +export const receiveSearchedItemsError = ({ commit }) => { + commit(types.RECEIVE_SEARCHED_ITEMS_ERROR); +}; +export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => { + dispatch('requestSearchedItems'); + + const params = { + simple: true, + per_page: 20, + membership: !!gon.current_user_id, + }; + + if (state.namespace === 'projects') { + params.order_by = 'last_activity_at'; + } + + return Api[state.namespace](searchQuery, params) + .then(results => { + dispatch('receiveSearchedItemsSuccess', results); + }) + .catch(() => { + dispatch('receiveSearchedItemsError'); + }); +}; + +export const setSearchQuery = ({ commit, dispatch }, query) => { + commit(types.SET_SEARCH_QUERY, query); + + if (query) { + dispatch('fetchSearchedItems', query); + } else { + dispatch('fetchFrequentItems'); + } +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/frequent_items/store/getters.js b/app/assets/javascripts/frequent_items/store/getters.js new file mode 100644 index 00000000000..00165db6684 --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/getters.js @@ -0,0 +1,4 @@ +export const hasSearchQuery = state => state.searchQuery !== ''; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/frequent_items/store/index.js b/app/assets/javascripts/frequent_items/store/index.js new file mode 100644 index 00000000000..ece9e6419dd --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + getters, + mutations, + state: state(), + }); diff --git a/app/assets/javascripts/frequent_items/store/mutation_types.js b/app/assets/javascripts/frequent_items/store/mutation_types.js new file mode 100644 index 00000000000..cbe2c9401ad --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/mutation_types.js @@ -0,0 +1,9 @@ +export const SET_NAMESPACE = 'SET_NAMESPACE'; +export const SET_STORAGE_KEY = 'SET_STORAGE_KEY'; +export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; +export const REQUEST_FREQUENT_ITEMS = 'REQUEST_FREQUENT_ITEMS'; +export const RECEIVE_FREQUENT_ITEMS_SUCCESS = 'RECEIVE_FREQUENT_ITEMS_SUCCESS'; +export const RECEIVE_FREQUENT_ITEMS_ERROR = 'RECEIVE_FREQUENT_ITEMS_ERROR'; +export const REQUEST_SEARCHED_ITEMS = 'REQUEST_SEARCHED_ITEMS'; +export const RECEIVE_SEARCHED_ITEMS_SUCCESS = 'RECEIVE_SEARCHED_ITEMS_SUCCESS'; +export const RECEIVE_SEARCHED_ITEMS_ERROR = 'RECEIVE_SEARCHED_ITEMS_ERROR'; diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js new file mode 100644 index 00000000000..41b660a243f --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/mutations.js @@ -0,0 +1,71 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_NAMESPACE](state, namespace) { + Object.assign(state, { + namespace, + }); + }, + [types.SET_STORAGE_KEY](state, storageKey) { + Object.assign(state, { + storageKey, + }); + }, + [types.SET_SEARCH_QUERY](state, searchQuery) { + const hasSearchQuery = searchQuery !== ''; + + Object.assign(state, { + searchQuery, + isLoadingItems: true, + hasSearchQuery, + }); + }, + [types.REQUEST_FREQUENT_ITEMS](state) { + Object.assign(state, { + isLoadingItems: true, + hasSearchQuery: false, + }); + }, + [types.RECEIVE_FREQUENT_ITEMS_SUCCESS](state, rawItems) { + Object.assign(state, { + items: rawItems, + isLoadingItems: false, + hasSearchQuery: false, + isFetchFailed: false, + }); + }, + [types.RECEIVE_FREQUENT_ITEMS_ERROR](state) { + Object.assign(state, { + isLoadingItems: false, + hasSearchQuery: false, + isFetchFailed: true, + }); + }, + [types.REQUEST_SEARCHED_ITEMS](state) { + Object.assign(state, { + isLoadingItems: true, + hasSearchQuery: true, + }); + }, + [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) { + Object.assign(state, { + items: rawItems.map(rawItem => ({ + id: rawItem.id, + name: rawItem.name, + namespace: rawItem.name_with_namespace || rawItem.full_name, + webUrl: rawItem.web_url, + avatarUrl: rawItem.avatar_url, + })), + isLoadingItems: false, + hasSearchQuery: true, + isFetchFailed: false, + }); + }, + [types.RECEIVE_SEARCHED_ITEMS_ERROR](state) { + Object.assign(state, { + isLoadingItems: false, + hasSearchQuery: true, + isFetchFailed: true, + }); + }, +}; diff --git a/app/assets/javascripts/frequent_items/store/state.js b/app/assets/javascripts/frequent_items/store/state.js new file mode 100644 index 00000000000..75b04febee4 --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/state.js @@ -0,0 +1,8 @@ +export default () => ({ + namespace: '', + storageKey: '', + searchQuery: '', + isLoadingItems: false, + isFetchFailed: false, + items: [], +}); diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js new file mode 100644 index 00000000000..aba692e4b99 --- /dev/null +++ b/app/assets/javascripts/frequent_items/utils.js @@ -0,0 +1,49 @@ +import _ from 'underscore'; +import bp from '~/breakpoints'; +import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants'; + +export const isMobile = () => { + const screenSize = bp.getBreakpointSize(); + + return screenSize === 'sm' || screenSize === 'xs'; +}; + +export const getTopFrequentItems = items => { + if (!items) { + return []; + } + const frequentItemsCount = isMobile() + ? FREQUENT_ITEMS.LIST_COUNT_MOBILE + : FREQUENT_ITEMS.LIST_COUNT_DESKTOP; + + const frequentItems = items.filter(item => item.frequency >= FREQUENT_ITEMS.ELIGIBLE_FREQUENCY); + + if (!frequentItems || frequentItems.length === 0) { + return []; + } + + frequentItems.sort((itemA, itemB) => { + // Sort all frequent items in decending order of frequency + // and then by lastAccessedOn with recent most first + if (itemA.frequency !== itemB.frequency) { + return itemB.frequency - itemA.frequency; + } else if (itemA.lastAccessedOn !== itemB.lastAccessedOn) { + return itemB.lastAccessedOn - itemA.lastAccessedOn; + } + + return 0; + }); + + return _.first(frequentItems, frequentItemsCount); +}; + +export const updateExistingFrequentItem = (frequentItem, item) => { + const accessedOverHourAgo = + Math.abs(item.lastAccessedOn - frequentItem.lastAccessedOn) / HOUR_IN_MS > 1; + + return { + ...item, + frequency: accessedOverHourAgo ? frequentItem.frequency + 1 : frequentItem.frequency, + lastAccessedOn: accessedOverHourAgo ? Date.now() : frequentItem.lastAccessedOn, + }; +}; diff --git a/app/assets/javascripts/ide/components/merge_requests/info.vue b/app/assets/javascripts/ide/components/merge_requests/info.vue new file mode 100644 index 00000000000..199d2e74971 --- /dev/null +++ b/app/assets/javascripts/ide/components/merge_requests/info.vue @@ -0,0 +1,43 @@ +<script> +import { mapGetters } from 'vuex'; +import Icon from '../../../vue_shared/components/icon.vue'; +import TitleComponent from '../../../issue_show/components/title.vue'; +import DescriptionComponent from '../../../issue_show/components/description.vue'; + +export default { + components: { + Icon, + TitleComponent, + DescriptionComponent, + }, + computed: { + ...mapGetters(['currentMergeRequest']), + }, +}; +</script> + +<template> + <div class="ide-merge-request-info h-100 d-flex flex-column"> + <div class="detail-page-header"> + <icon + name="git-merge" + class="align-self-center append-right-8" + /> + <strong> + !{{ currentMergeRequest.iid }} + </strong> + </div> + <div class="issuable-details"> + <title-component + :issuable-ref="currentMergeRequest.iid" + :title-html="currentMergeRequest.title_html" + :title-text="currentMergeRequest.title" + /> + <description-component + :description-html="currentMergeRequest.description_html" + :description-text="currentMergeRequest.description" + :can-update="false" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index 5cd2c9ce188..e4a5fcc67c4 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -5,6 +5,7 @@ import Icon from '../../../vue_shared/components/icon.vue'; import { rightSidebarViews } from '../../constants'; import PipelinesList from '../pipelines/list.vue'; import JobsDetail from '../jobs/detail.vue'; +import MergeRequestInfo from '../merge_requests/info.vue'; import ResizablePanel from '../resizable_panel.vue'; export default { @@ -16,9 +17,10 @@ export default { PipelinesList, JobsDetail, ResizablePanel, + MergeRequestInfo, }, computed: { - ...mapState(['rightPane']), + ...mapState(['rightPane', 'currentMergeRequestId']), pipelinesActive() { return ( this.rightPane === rightSidebarViews.pipelines || @@ -54,10 +56,33 @@ export default { </resizable-panel> <nav class="ide-activity-bar"> <ul class="list-unstyled"> + <li + v-if="currentMergeRequestId" + > + <button + v-tooltip + :title="__('Merge Request')" + :aria-label="__('Merge Request')" + :class="{ + active: rightPane === $options.rightSidebarViews.mergeRequestInfo + }" + data-container="body" + data-placement="left" + class="ide-sidebar-link is-right" + type="button" + @click="clickTab($event, $options.rightSidebarViews.mergeRequestInfo)" + > + <icon + :size="16" + name="text-description" + /> + </button> + </li> <li> <button v-tooltip :title="__('Pipelines')" + :aria-label="__('Pipelines')" :class="{ active: pipelinesActive }" diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 12e0c3aeef0..45d36f6f42c 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -31,6 +31,7 @@ export const diffModes = { export const rightSidebarViews = { pipelines: 'pipelines-list', jobsDetail: 'jobs-detail', + mergeRequestInfo: 'merge-request-info', }; export const stageKeys = { diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 3e939f0c1a3..49a481f25d5 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -40,8 +40,8 @@ export default { getProjectData(namespace, project) { return Api.project(`${namespace}/${project}`); }, - getProjectMergeRequestData(projectId, mergeRequestId) { - return Api.mergeRequest(projectId, mergeRequestId); + getProjectMergeRequestData(projectId, mergeRequestId, params = {}) { + return Api.mergeRequest(projectId, mergeRequestId, params); }, getProjectMergeRequestChanges(projectId, mergeRequestId) { return Api.mergeRequestChanges(projectId, mergeRequestId); diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 4aa151abcb7..6bdf9dc3028 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -9,7 +9,7 @@ export const getMergeRequestData = ( new Promise((resolve, reject) => { if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) { service - .getProjectMergeRequestData(projectId, mergeRequestId) + .getProjectMergeRequestData(projectId, mergeRequestId, { render_html: true }) .then(({ data }) => { commit(types.SET_MERGE_REQUEST, { projectPath: projectId, diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index b6364318537..ad928484952 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -108,6 +108,11 @@ type: String, required: true, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, projectPath: { type: String, required: true, @@ -282,6 +287,7 @@ :issuable-templates="issuableTemplates" :markdown-docs-path="markdownDocsPath" :markdown-preview-path="markdownPreviewPath" + :markdown-version="markdownVersion" :project-path="projectPath" :project-namespace="projectNamespace" :show-delete-button="showDeleteButton" diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 5f58f671c73..97acc5ba385 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -20,6 +20,11 @@ type: String, required: true, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, canAttachFile: { type: Boolean, required: false, @@ -47,6 +52,7 @@ <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" + :markdown-version="markdownVersion" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" > diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index 5bfc072e3da..e509bb52f7d 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -35,6 +35,11 @@ type: String, required: true, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, projectPath: { type: String, required: true, @@ -97,6 +102,7 @@ :form-state="formState" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" + :markdown-version="markdownVersion" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" /> diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index 12101c0daa5..b5e8e0ea44b 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -1,67 +1,67 @@ <script> - import animateMixin from '../mixins/animate'; - import eventHub from '../event_hub'; - import tooltip from '../../vue_shared/directives/tooltip'; - import { spriteIcon } from '../../lib/utils/common_utils'; +import animateMixin from '../mixins/animate'; +import eventHub from '../event_hub'; +import tooltip from '../../vue_shared/directives/tooltip'; +import { spriteIcon } from '../../lib/utils/common_utils'; - export default { - directives: { - tooltip, +export default { + directives: { + tooltip, + }, + mixins: [animateMixin], + props: { + issuableRef: { + type: [String, Number], + required: true, }, - mixins: [animateMixin], - props: { - issuableRef: { - type: String, - required: true, - }, - canUpdate: { - required: false, - type: Boolean, - default: false, - }, - titleHtml: { - type: String, - required: true, - }, - titleText: { - type: String, - required: true, - }, - showInlineEditButton: { - type: Boolean, - required: false, - default: false, - }, + canUpdate: { + required: false, + type: Boolean, + default: false, }, - data() { - return { - preAnimation: false, - pulseAnimation: false, - titleEl: document.querySelector('title'), - }; + titleHtml: { + type: String, + required: true, }, - computed: { - pencilIcon() { - return spriteIcon('pencil', 'link-highlight'); - }, + titleText: { + type: String, + required: true, }, - watch: { - titleHtml() { - this.setPageTitle(); - this.animateChange(); - }, + showInlineEditButton: { + type: Boolean, + required: false, + default: false, }, - methods: { - setPageTitle() { - const currentPageTitleScope = this.titleEl.innerText.split('·'); - currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; - this.titleEl.textContent = currentPageTitleScope.join('·'); - }, - edit() { - eventHub.$emit('open.form'); - }, + }, + data() { + return { + preAnimation: false, + pulseAnimation: false, + titleEl: document.querySelector('title'), + }; + }, + computed: { + pencilIcon() { + return spriteIcon('pencil', 'link-highlight'); }, - }; + }, + watch: { + titleHtml() { + this.setPageTitle(); + this.animateChange(); + }, + }, + methods: { + setPageTitle() { + const currentPageTitleScope = this.titleEl.innerText.split('·'); + currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; + this.titleEl.textContent = currentPageTitleScope.join('·'); + }, + edit() { + eventHub.$emit('open.form'); + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index c9ce838cd48..2718f73a830 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -26,7 +26,7 @@ import './feature_highlight/feature_highlight_options'; import LazyLoader from './lazy_loader'; import initLogoAnimation from './logo'; import './milestone_select'; -import './projects_dropdown'; +import './frequent_items'; import initBreadcrumbs from './breadcrumb'; import initDispatcher from './dispatcher'; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 48cda28a1ae..8124ae6201f 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1251,13 +1251,15 @@ export default class Notes { var postUrl = $originalContentEl.data('postUrl'); var targetId = $originalContentEl.data('targetId'); var targetType = $originalContentEl.data('targetType'); + var markdownVersion = $originalContentEl.data('markdownVersion'); this.glForm = new GLForm($editForm.find('form'), this.enableGFM); $editForm .find('form') .attr('action', `${postUrl}?html=true`) - .attr('data-remote', 'true'); + .attr('data-remote', 'true') + .attr('data-markdown-version', markdownVersion); $editForm.find('.js-form-target-id').val(targetId); $editForm.find('.js-form-target-type').val(targetType); $editForm diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index c6a524f68cb..6612bc44e0b 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -34,6 +34,11 @@ export default { type: String, required: true, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -344,6 +349,7 @@ Please check your network connection and try again.`; :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" + :markdown-version="markdownVersion" :add-spacing-classes="false"> <textarea id="note-body" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index d2db68df98e..6f4a0709825 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -92,6 +92,7 @@ export default { :is-editing="isEditing" :note-body="noteBody" :note-id="note.id" + :markdown-version="note.cached_markdown_version" @handleFormUpdate="handleFormUpdate" @cancelForm="formCancelHandler" /> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index a4e3faa5d75..963e3a37b39 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -24,6 +24,11 @@ export default { required: false, default: 0, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, saveButtonTitle: { type: String, required: false, @@ -156,6 +161,7 @@ export default { <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" + :markdown-version="markdownVersion" :quick-actions-docs-path="quickActionsDocsPath" :add-spacing-classes="false"> <textarea diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index a8995021699..9b8713b40fb 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -43,6 +43,11 @@ export default { required: false, default: true, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -192,6 +197,7 @@ export default { <comment-form :noteable-type="noteableType" + :markdown-version="markdownVersion" /> </div> </template> diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index eed3a82854d..6dd4c9d66ac 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -15,6 +15,7 @@ document.addEventListener('DOMContentLoaded', () => { const notesDataset = document.getElementById('js-vue-notes').dataset; const parsedUserData = JSON.parse(notesDataset.currentUserData); const noteableData = JSON.parse(notesDataset.noteableData); + const { markdownVersion } = notesDataset; let currentUserData = {}; noteableData.noteableType = notesDataset.noteableType; @@ -33,6 +34,7 @@ document.addEventListener('DOMContentLoaded', () => { return { noteableData, currentUserData, + markdownVersion, notesData: JSON.parse(notesDataset.notesData), }; }, @@ -42,6 +44,7 @@ document.addEventListener('DOMContentLoaded', () => { noteableData: this.noteableData, notesData: this.notesData, userData: this.currentUserData, + markdownVersion: this.markdownVersion, }, }); }, diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js new file mode 100644 index 00000000000..1cd3ee1dfdb --- /dev/null +++ b/app/assets/javascripts/pages/profiles/keys/index.js @@ -0,0 +1,16 @@ +import AddSshKeyValidation from '~/profile/add_ssh_key_validation'; + +document.addEventListener('DOMContentLoaded', () => { + const input = document.querySelector('.js-add-ssh-key-validation-input'); + const warning = document.querySelector('.js-add-ssh-key-validation-warning'); + const originalSubmit = input.form.querySelector('.js-add-ssh-key-validation-original-submit'); + const confirmSubmit = warning.querySelector('.js-add-ssh-key-validation-confirm-submit'); + + const addSshKeyValidation = new AddSshKeyValidation( + input, + warning, + originalSubmit, + confirmSubmit, + ); + addSshKeyValidation.register(); +}); diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js index 0e973cab4d2..0964baf8954 100644 --- a/app/assets/javascripts/preview_markdown.js +++ b/app/assets/javascripts/preview_markdown.js @@ -28,12 +28,16 @@ MarkdownPreview.prototype.ajaxCache = {}; MarkdownPreview.prototype.showPreview = function ($form) { var mdText; + var markdownVersion; + var url; var preview = $form.find('.js-md-preview'); - var url = preview.data('url'); if (preview.hasClass('md-preview-loading')) { return; } + mdText = $form.find('textarea.markdown-area').val(); + markdownVersion = $form.attr('data-markdown-version'); + url = this.versionedPreviewPath(preview.data('url'), markdownVersion); if (mdText.trim().length === 0) { preview.text(this.emptyMessage); @@ -59,6 +63,14 @@ MarkdownPreview.prototype.showPreview = function ($form) { } }; +MarkdownPreview.prototype.versionedPreviewPath = function (markdownPreviewPath, markdownVersion) { + if (typeof markdownVersion === 'undefined') { + return markdownPreviewPath; + } + + return `${markdownPreviewPath}${markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'}markdown_version=${markdownVersion}`; +}; + MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) { if (!url) { return; diff --git a/app/assets/javascripts/profile/add_ssh_key_validation.js b/app/assets/javascripts/profile/add_ssh_key_validation.js new file mode 100644 index 00000000000..ab6a6c1896c --- /dev/null +++ b/app/assets/javascripts/profile/add_ssh_key_validation.js @@ -0,0 +1,43 @@ +export default class AddSshKeyValidation { + constructor(inputElement, warningElement, originalSubmitElement, confirmSubmitElement) { + this.inputElement = inputElement; + this.form = inputElement.form; + + this.warningElement = warningElement; + + this.originalSubmitElement = originalSubmitElement; + this.confirmSubmitElement = confirmSubmitElement; + + this.isValid = false; + } + + register() { + this.form.addEventListener('submit', event => this.submit(event)); + + this.confirmSubmitElement.addEventListener('click', () => { + this.isValid = true; + this.form.submit(); + }); + + this.inputElement.addEventListener('input', () => this.toggleWarning(false)); + } + + submit(event) { + this.isValid = AddSshKeyValidation.isPublicKey(this.inputElement.value); + + if (this.isValid) return true; + + event.preventDefault(); + this.toggleWarning(true); + return false; + } + + toggleWarning(isVisible) { + this.warningElement.classList.toggle('hide', !isVisible); + this.originalSubmitElement.classList.toggle('hide', isVisible); + } + + static isPublicKey(value) { + return /^(ssh|ecdsa-sha2)-/.test(value); + } +} diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue deleted file mode 100644 index 73d49488299..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/app.vue +++ /dev/null @@ -1,158 +0,0 @@ -<script> -import bs from '../../breakpoints'; -import eventHub from '../event_hub'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - -import projectsListFrequent from './projects_list_frequent.vue'; -import projectsListSearch from './projects_list_search.vue'; - -import search from './search.vue'; - -export default { - components: { - search, - loadingIcon, - projectsListFrequent, - projectsListSearch, - }, - props: { - currentProject: { - type: Object, - required: true, - }, - store: { - type: Object, - required: true, - }, - service: { - type: Object, - required: true, - }, - }, - data() { - return { - isLoadingProjects: false, - isFrequentsListVisible: false, - isSearchListVisible: false, - isLocalStorageFailed: false, - isSearchFailed: false, - searchQuery: '', - }; - }, - computed: { - frequentProjects() { - return this.store.getFrequentProjects(); - }, - searchProjects() { - return this.store.getSearchedProjects(); - }, - }, - created() { - if (this.currentProject.id) { - this.logCurrentProjectAccess(); - } - - eventHub.$on('dropdownOpen', this.fetchFrequentProjects); - eventHub.$on('searchProjects', this.fetchSearchedProjects); - eventHub.$on('searchCleared', this.handleSearchClear); - eventHub.$on('searchFailed', this.handleSearchFailure); - }, - beforeDestroy() { - eventHub.$off('dropdownOpen', this.fetchFrequentProjects); - eventHub.$off('searchProjects', this.fetchSearchedProjects); - eventHub.$off('searchCleared', this.handleSearchClear); - eventHub.$off('searchFailed', this.handleSearchFailure); - }, - methods: { - toggleFrequentProjectsList(state) { - this.isLoadingProjects = !state; - this.isSearchListVisible = !state; - this.isFrequentsListVisible = state; - }, - toggleSearchProjectsList(state) { - this.isLoadingProjects = !state; - this.isFrequentsListVisible = !state; - this.isSearchListVisible = state; - }, - toggleLoader(state) { - this.isFrequentsListVisible = !state; - this.isSearchListVisible = !state; - this.isLoadingProjects = state; - }, - fetchFrequentProjects() { - const screenSize = bs.getBreakpointSize(); - if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) { - this.toggleSearchProjectsList(true); - } else { - this.toggleLoader(true); - this.isLocalStorageFailed = false; - const projects = this.service.getFrequentProjects(); - if (projects) { - this.toggleFrequentProjectsList(true); - this.store.setFrequentProjects(projects); - } else { - this.isLocalStorageFailed = true; - this.toggleFrequentProjectsList(true); - this.store.setFrequentProjects([]); - } - } - }, - fetchSearchedProjects(searchQuery) { - this.searchQuery = searchQuery; - this.toggleLoader(true); - this.service - .getSearchedProjects(this.searchQuery) - .then(res => res.json()) - .then(results => { - this.toggleSearchProjectsList(true); - this.store.setSearchedProjects(results); - }) - .catch(() => { - this.isSearchFailed = true; - this.toggleSearchProjectsList(true); - }); - }, - logCurrentProjectAccess() { - this.service.logProjectAccess(this.currentProject); - }, - handleSearchClear() { - this.searchQuery = ''; - this.toggleFrequentProjectsList(true); - this.store.clearSearchedProjects(); - }, - handleSearchFailure() { - this.isSearchFailed = true; - this.toggleSearchProjectsList(true); - }, - }, -}; -</script> - -<template> - <div> - <search/> - <loading-icon - v-if="isLoadingProjects" - :label="s__('ProjectsDropdown|Loading projects')" - class="loading-animation prepend-top-20" - size="2" - /> - <div - v-if="isFrequentsListVisible" - class="section-header" - > - {{ s__('ProjectsDropdown|Frequently visited') }} - </div> - <projects-list-frequent - v-if="isFrequentsListVisible" - :local-storage-failed="isLocalStorageFailed" - :projects="frequentProjects" - /> - <projects-list-search - v-if="isSearchListVisible" - :search-failed="isSearchFailed" - :matcher="searchQuery" - :projects="searchProjects" - /> - </div> -</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue deleted file mode 100644 index 625e0aa548c..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue +++ /dev/null @@ -1,57 +0,0 @@ -<script> - import { s__ } from '../../locale'; - import projectsListItem from './projects_list_item.vue'; - - export default { - components: { - projectsListItem, - }, - props: { - projects: { - type: Array, - required: true, - }, - localStorageFailed: { - type: Boolean, - required: true, - }, - }, - computed: { - isListEmpty() { - return this.projects.length === 0; - }, - listEmptyMessage() { - return this.localStorageFailed ? - s__('ProjectsDropdown|This feature requires browser localStorage support') : - s__('ProjectsDropdown|Projects you visit often will appear here'); - }, - }, - }; -</script> - -<template> - <div - class="projects-list-frequent-container" - > - <ul - class="list-unstyled" - > - <li - v-if="isListEmpty" - class="section-empty" - > - {{ listEmptyMessage }} - </li> - <projects-list-item - v-for="(project, index) in projects" - v-else - :key="index" - :project-id="project.id" - :project-name="project.name" - :namespace="project.namespace" - :web-url="project.webUrl" - :avatar-url="project.avatarUrl" - /> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue deleted file mode 100644 index eafbf6c99e2..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue +++ /dev/null @@ -1,116 +0,0 @@ -<script> - /* eslint-disable vue/require-default-prop, vue/require-prop-types */ - import identicon from '../../vue_shared/components/identicon.vue'; - - export default { - components: { - identicon, - }, - props: { - matcher: { - type: String, - required: false, - }, - projectId: { - type: Number, - required: true, - }, - projectName: { - type: String, - required: true, - }, - namespace: { - type: String, - required: true, - }, - webUrl: { - type: String, - required: true, - }, - avatarUrl: { - required: true, - validator(value) { - return value === null || typeof value === 'string'; - }, - }, - }, - computed: { - hasAvatar() { - return this.avatarUrl !== null; - }, - highlightedProjectName() { - if (this.matcher) { - const matcherRegEx = new RegExp(this.matcher, 'gi'); - const matches = this.projectName.match(matcherRegEx); - - if (matches && matches.length > 0) { - return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`); - } - } - return this.projectName; - }, - /** - * Smartly truncates project namespace by doing two things; - * 1. Only include Group names in path by removing project name - * 2. Only include first and last group names in the path - * when namespace has more than 2 groups present - * - * First part (removal of project name from namespace) can be - * done from backend but doing so involves migration of - * existing project namespaces which is not wise thing to do. - */ - truncatedNamespace() { - const namespaceArr = this.namespace.split(' / '); - namespaceArr.splice(-1, 1); - let namespace = namespaceArr.join(' / '); - - if (namespaceArr.length > 2) { - namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`; - } - - return namespace; - }, - }, - }; -</script> - -<template> - <li - class="projects-list-item-container" - > - <a - :href="webUrl" - class="clearfix" - > - <div - class="project-item-avatar-container" - > - <img - v-if="hasAvatar" - :src="avatarUrl" - class="avatar s32" - /> - <identicon - v-else - :entity-id="projectId" - :entity-name="projectName" - size-class="s32" - /> - </div> - <div - class="project-item-metadata-container" - > - <div - :title="projectName" - class="project-title" - v-html="highlightedProjectName" - > - </div> - <div - :title="namespace" - class="project-namespace" - >{{ truncatedNamespace }}</div> - </div> - </a> - </li> -</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue deleted file mode 100644 index 76e9cb9e53f..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { s__ } from '../../locale'; -import projectsListItem from './projects_list_item.vue'; - -export default { - components: { - projectsListItem, - }, - props: { - matcher: { - type: String, - required: true, - }, - projects: { - type: Array, - required: true, - }, - searchFailed: { - type: Boolean, - required: true, - }, - }, - computed: { - isListEmpty() { - return this.projects.length === 0; - }, - listEmptyMessage() { - return this.searchFailed ? - s__('ProjectsDropdown|Something went wrong on our end.') : - s__('ProjectsDropdown|Sorry, no projects matched your search'); - }, - }, -}; -</script> - -<template> - <div - class="projects-list-search-container" - > - <ul - class="list-unstyled" - > - <li - v-if="isListEmpty" - :class="{ 'section-failure': searchFailed }" - class="section-empty" - > - {{ listEmptyMessage }} - </li> - <projects-list-item - v-for="(project, index) in projects" - v-else - :key="index" - :project-id="project.id" - :project-name="project.name" - :namespace="project.namespace" - :web-url="project.webUrl" - :avatar-url="project.avatarUrl" - :matcher="matcher" - /> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/projects_dropdown/components/search.vue b/app/assets/javascripts/projects_dropdown/components/search.vue deleted file mode 100644 index 28f2a18f2a6..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/search.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> - import _ from 'underscore'; - import eventHub from '../event_hub'; - - export default { - data() { - return { - searchQuery: '', - }; - }, - watch: { - searchQuery() { - this.handleInput(); - }, - }, - mounted() { - eventHub.$on('dropdownOpen', this.setFocus); - }, - beforeDestroy() { - eventHub.$off('dropdownOpen', this.setFocus); - }, - methods: { - setFocus() { - this.$refs.search.focus(); - }, - emitSearchEvents() { - if (this.searchQuery) { - eventHub.$emit('searchProjects', this.searchQuery); - } else { - eventHub.$emit('searchCleared'); - } - }, - /** - * Callback function within _.debounce is intentionally - * kept as ES5 `function() {}` instead of ES6 `() => {}` - * as it otherwise messes up function context - * and component reference is no longer accessible via `this` - */ - // eslint-disable-next-line func-names - handleInput: _.debounce(function () { - this.emitSearchEvents(); - }, 500), - }, - }; -</script> - -<template> - <div - class="search-input-container d-none d-sm-block" - > - <input - ref="search" - v-model="searchQuery" - :placeholder="s__('ProjectsDropdown|Search your projects')" - type="search" - class="form-control" - /> - <i - v-if="!searchQuery" - class="search-icon fa fa-fw fa-search" - aria-hidden="true" - > - </i> - </div> -</template> diff --git a/app/assets/javascripts/projects_dropdown/constants.js b/app/assets/javascripts/projects_dropdown/constants.js deleted file mode 100644 index 8937097184c..00000000000 --- a/app/assets/javascripts/projects_dropdown/constants.js +++ /dev/null @@ -1,10 +0,0 @@ -export const FREQUENT_PROJECTS = { - MAX_COUNT: 20, - LIST_COUNT_DESKTOP: 5, - LIST_COUNT_MOBILE: 3, - ELIGIBLE_FREQUENCY: 3, -}; - -export const HOUR_IN_MS = 3600000; - -export const STORAGE_KEY = 'frequent-projects'; diff --git a/app/assets/javascripts/projects_dropdown/index.js b/app/assets/javascripts/projects_dropdown/index.js deleted file mode 100644 index 6056f12aa4f..00000000000 --- a/app/assets/javascripts/projects_dropdown/index.js +++ /dev/null @@ -1,66 +0,0 @@ -import $ from 'jquery'; -import Vue from 'vue'; - -import Translate from '../vue_shared/translate'; -import eventHub from './event_hub'; -import ProjectsService from './service/projects_service'; -import ProjectsStore from './store/projects_store'; - -import projectsDropdownApp from './components/app.vue'; - -Vue.use(Translate); - -document.addEventListener('DOMContentLoaded', () => { - const el = document.getElementById('js-projects-dropdown'); - const navEl = document.getElementById('nav-projects-dropdown'); - - // Don't do anything if element doesn't exist (No projects dropdown) - // This is for when the user accesses GitLab without logging in - if (!el || !navEl) { - return; - } - - $(navEl).on('shown.bs.dropdown', () => { - eventHub.$emit('dropdownOpen'); - }); - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - projectsDropdownApp, - }, - data() { - const { dataset } = this.$options.el; - const store = new ProjectsStore(); - const service = new ProjectsService(dataset.userName); - - const project = { - id: Number(dataset.projectId), - name: dataset.projectName, - namespace: dataset.projectNamespace, - webUrl: dataset.projectWebUrl, - avatarUrl: dataset.projectAvatarUrl || null, - lastAccessedOn: Date.now(), - }; - - return { - store, - service, - state: store.state, - currentUserName: dataset.userName, - currentProject: project, - }; - }, - render(createElement) { - return createElement('projects-dropdown-app', { - props: { - currentUserName: this.currentUserName, - currentProject: this.currentProject, - store: this.store, - service: this.service, - }, - }); - }, - }); -}); diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js deleted file mode 100644 index ed1c3deead2..00000000000 --- a/app/assets/javascripts/projects_dropdown/service/projects_service.js +++ /dev/null @@ -1,137 +0,0 @@ -import _ from 'underscore'; -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -import bp from '../../breakpoints'; -import Api from '../../api'; -import AccessorUtilities from '../../lib/utils/accessor'; - -import { FREQUENT_PROJECTS, HOUR_IN_MS, STORAGE_KEY } from '../constants'; - -Vue.use(VueResource); - -export default class ProjectsService { - constructor(currentUserName) { - this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); - this.currentUserName = currentUserName; - this.storageKey = `${this.currentUserName}/${STORAGE_KEY}`; - this.projectsPath = Vue.resource(Api.buildUrl(Api.projectsPath)); - } - - getSearchedProjects(searchQuery) { - return this.projectsPath.get({ - simple: true, - per_page: 20, - membership: !!gon.current_user_id, - order_by: 'last_activity_at', - search: searchQuery, - }); - } - - getFrequentProjects() { - if (this.isLocalStorageAvailable) { - return this.getTopFrequentProjects(); - } - return null; - } - - logProjectAccess(project) { - let matchFound = false; - let storedFrequentProjects; - - if (this.isLocalStorageAvailable) { - const storedRawProjects = localStorage.getItem(this.storageKey); - - // Check if there's any frequent projects list set - if (!storedRawProjects) { - // No frequent projects list set, set one up. - storedFrequentProjects = []; - storedFrequentProjects.push({ ...project, frequency: 1 }); - } else { - // Check if project is already present in frequents list - // When found, update metadata of it. - storedFrequentProjects = JSON.parse(storedRawProjects).map(projectItem => { - if (projectItem.id === project.id) { - matchFound = true; - const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS; - const updatedProject = { - ...project, - frequency: projectItem.frequency, - lastAccessedOn: projectItem.lastAccessedOn, - }; - - // Check if duration since last access of this project - // is over an hour - if (diff > 1) { - return { - ...updatedProject, - frequency: updatedProject.frequency + 1, - lastAccessedOn: Date.now(), - }; - } - - return { - ...updatedProject, - }; - } - - return projectItem; - }); - - // Check whether currently logged project is present in frequents list - if (!matchFound) { - // We always keep size of frequents collection to 20 projects - // out of which only 5 projects with - // highest value of `frequency` and most recent `lastAccessedOn` - // are shown in projects dropdown - if (storedFrequentProjects.length === FREQUENT_PROJECTS.MAX_COUNT) { - storedFrequentProjects.shift(); // Remove an item from head of array - } - - storedFrequentProjects.push({ ...project, frequency: 1 }); - } - } - - localStorage.setItem(this.storageKey, JSON.stringify(storedFrequentProjects)); - } - } - - getTopFrequentProjects() { - const storedFrequentProjects = JSON.parse(localStorage.getItem(this.storageKey)); - let frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_DESKTOP; - - if (!storedFrequentProjects) { - return []; - } - - if (bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'xs') { - frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE; - } - - const frequentProjects = storedFrequentProjects.filter( - project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY, - ); - - if (!frequentProjects || frequentProjects.length === 0) { - return []; - } - - // Sort all frequent projects in decending order of frequency - // and then by lastAccessedOn with recent most first - frequentProjects.sort((projectA, projectB) => { - if (projectA.frequency < projectB.frequency) { - return 1; - } else if (projectA.frequency > projectB.frequency) { - return -1; - } else if (projectA.lastAccessedOn < projectB.lastAccessedOn) { - return 1; - } else if (projectA.lastAccessedOn > projectB.lastAccessedOn) { - return -1; - } - - return 0; - }); - - return _.first(frequentProjects, frequentProjectsCount); - } -} diff --git a/app/assets/javascripts/projects_dropdown/store/projects_store.js b/app/assets/javascripts/projects_dropdown/store/projects_store.js deleted file mode 100644 index ffefbe693f4..00000000000 --- a/app/assets/javascripts/projects_dropdown/store/projects_store.js +++ /dev/null @@ -1,33 +0,0 @@ -export default class ProjectsStore { - constructor() { - this.state = {}; - this.state.frequentProjects = []; - this.state.searchedProjects = []; - } - - setFrequentProjects(rawProjects) { - this.state.frequentProjects = rawProjects; - } - - getFrequentProjects() { - return this.state.frequentProjects; - } - - setSearchedProjects(rawProjects) { - this.state.searchedProjects = rawProjects.map(rawProject => ({ - id: rawProject.id, - name: rawProject.name, - namespace: rawProject.name_with_namespace, - webUrl: rawProject.web_url, - avatarUrl: rawProject.avatar_url, - })); - } - - getSearchedProjects() { - return this.state.searchedProjects; - } - - clearSearchedProjects() { - this.state.searchedProjects = []; - } -} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index 5e464f8a0e2..21f21232596 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -79,66 +79,62 @@ export default { </script> <template> - <div class="mr-widget-heading deploy-heading"> + <div class="mr-widget-heading deploy-heading append-bottom-default"> <div class="ci-widget media"> - <div class="ci-status-icon ci-status-icon-success"> - <span class="js-icon-link icon-link"> - <status-icon status="success" /> - </span> - </div> <div class="media-body"> <div class="deploy-body"> - <template v-if="hasDeploymentMeta"> - <span> - Deployed to - </span> - <a - :href="deployment.url" - target="_blank" - rel="noopener noreferrer nofollow" - class="deploy-link js-deploy-meta" + <div class="deployment-info"> + <template v-if="hasDeploymentMeta"> + <span> + Deployed to + </span> + <a + :href="deployment.url" + target="_blank" + rel="noopener noreferrer nofollow" + class="deploy-link js-deploy-meta" + > + {{ deployment.name }} + </a> + </template> + <span + v-tooltip + v-if="hasDeploymentTime" + :title="deployment.deployed_at_formatted" + class="js-deploy-time" > - {{ deployment.name }} - </a> - </template> - <template v-if="hasExternalUrls"> - <span> - on + {{ deployTimeago }} </span> + <memory-usage + v-if="hasMetrics" + :metrics-url="deployment.metrics_url" + :metrics-monitoring-url="deployment.metrics_monitoring_url" + /> + </div> + <div> <a + v-if="hasExternalUrls" :href="deployment.external_url" target="_blank" rel="noopener noreferrer nofollow" - class="deploy-link js-deploy-url" + class="deploy-link js-deploy-url btn btn-default btn-sm inline" > - {{ deployment.external_url_formatted }} - <icon - :size="16" - name="external-link" - /> + <span> + View app + <icon name="external-link" /> + </span> </a> - </template> - <span - v-tooltip - v-if="hasDeploymentTime" - :title="deployment.deployed_at_formatted" - class="js-deploy-time" - > - {{ deployTimeago }} - </span> - <loading-button - v-if="deployment.stop_url" - :loading="isStopping" - container-class="btn btn-default btn-sm prepend-left-default" - label="Stop environment" - @click="stopEnvironment" - /> + <loading-button + v-if="deployment.stop_url" + :loading="isStopping" + container-class="btn btn-default btn-sm inline prepend-left-4" + title="Stop environment" + @click="stopEnvironment" + > + <icon name="stop" /> + </loading-button> + </div> </div> - <memory-usage - v-if="hasMetrics" - :metrics-url="deployment.metrics_url" - :metrics-monitoring-url="deployment.metrics_monitoring_url" - /> </div> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 3ce9d8dc26a..c18b74743e4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -2,7 +2,7 @@ import tooltip from '~/vue_shared/directives/tooltip'; import { n__ } from '~/locale'; import { webIDEUrl } from '~/lib/utils/url_utility'; -import icon from '~/vue_shared/components/icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; export default { @@ -11,7 +11,7 @@ export default { tooltip, }, components: { - icon, + Icon, clipboardButton, }, props: { @@ -54,104 +54,114 @@ export default { }; </script> <template> - <div class="mr-source-target"> - <div class="normal"> - <strong> - {{ s__("mrWidget|Request to merge") }} - <span - :class="{ 'label-truncated': isSourceBranchLong }" - :title="isSourceBranchLong ? mr.sourceBranch : ''" - :v-tooltip="isSourceBranchLong" - class="label-branch js-source-branch" - data-placement="bottom" - v-html="mr.sourceBranchLink" - > - </span> + <div class="mr-source-target append-bottom-default"> + <div class="git-merge-icon-container append-right-default"> + <icon name="git-merge" /> + </div> + <div class="git-merge-container d-flex"> + <div class="normal"> + <strong> + {{ s__("mrWidget|Request to merge") }} + <span + :class="{ 'label-truncated': isSourceBranchLong }" + :title="isSourceBranchLong ? mr.sourceBranch : ''" + :v-tooltip="isSourceBranchLong" + class="label-branch js-source-branch" + data-placement="bottom" + v-html="mr.sourceBranchLink" + > + </span> - <clipboard-button - :text="branchNameClipboardData" - :title="__('Copy branch name to clipboard')" - css-class="btn-default btn-transparent btn-clipboard" - /> + <clipboard-button + :text="branchNameClipboardData" + :title="__('Copy branch name to clipboard')" + css-class="btn-default btn-transparent btn-clipboard" + /> - {{ s__("mrWidget|into") }} + {{ s__("mrWidget|into") }} - <span - :v-tooltip="isTargetBranchLong" - :class="{ 'label-truncatedtooltip': isTargetBranchLong }" - :title="isTargetBranchLong ? mr.targetBranch : ''" - class="label-branch" - data-placement="bottom" - > - <a - :href="mr.targetBranchTreePath" - class="js-target-branch" + <span + :v-tooltip="isTargetBranchLong" + :class="{ 'label-truncatedtooltip': isTargetBranchLong }" + :title="isTargetBranchLong ? mr.targetBranch : ''" + class="label-branch" + data-placement="bottom" > - {{ mr.targetBranch }} - </a> - </span> - </strong> - <span - v-if="shouldShowCommitsBehindText" - class="diverged-commits-count" - > - (<a :href="mr.targetBranchPath">{{ commitsText }}</a>) - </span> - </div> + <a + :href="mr.targetBranchTreePath" + class="js-target-branch" + > + {{ mr.targetBranch }} + </a> + </span> + </strong> + <div + v-if="shouldShowCommitsBehindText" + class="diverged-commits-count" + > + <span class="monospace">{{ mr.sourceBranch }}</span> + is {{ commitsText }} + <span class="monospace">{{ mr.targetBranch }}</span> + </div> + </div> - <div v-if="mr.isOpen"> - <a - v-if="!mr.sourceBranchRemoved" - :href="webIdePath" - class="btn btn-sm btn-default inline js-web-ide" - > - {{ s__("mrWidget|Web IDE") }} - </a> - <button - :disabled="mr.sourceBranchRemoved" - data-target="#modal_merge_info" - data-toggle="modal" - class="btn btn-sm btn-default inline js-check-out-branch" - type="button" + <div + v-if="mr.isOpen" + class="branch-actions" > - {{ s__("mrWidget|Check out branch") }} - </button> - <span class="dropdown prepend-left-10"> + <a + v-if="!mr.sourceBranchRemoved" + :href="webIdePath" + class="btn btn-default inline js-web-ide d-none d-md-inline-block" + > + {{ s__("mrWidget|Open in Web IDE") }} + </a> <button + :disabled="mr.sourceBranchRemoved" + data-target="#modal_merge_info" + data-toggle="modal" + class="btn btn-default inline js-check-out-branch" type="button" - class="btn btn-sm inline dropdown-toggle" - data-toggle="dropdown" - aria-label="Download as" - aria-haspopup="true" - aria-expanded="false" > - <icon name="download" /> - <i - class="fa fa-caret-down" - aria-hidden="true"> - </i> + {{ s__("mrWidget|Check out branch") }} </button> - <ul class="dropdown-menu dropdown-menu-right"> - <li> - <a - :href="mr.emailPatchesPath" - class="js-download-email-patches" - download - > - {{ s__("mrWidget|Email patches") }} - </a> - </li> - <li> - <a - :href="mr.plainDiffPath" - class="js-download-plain-diff" - download - > - {{ s__("mrWidget|Plain diff") }} - </a> - </li> - </ul> - </span> + <span class="dropdown prepend-left-10"> + <button + type="button" + class="btn inline dropdown-toggle" + data-toggle="dropdown" + aria-label="Download as" + aria-haspopup="true" + aria-expanded="false" + > + <icon name="download" /> + <i + class="fa fa-caret-down" + aria-hidden="true"> + </i> + </button> + <ul class="dropdown-menu dropdown-menu-right"> + <li> + <a + :href="mr.emailPatchesPath" + class="js-download-email-patches" + download + > + {{ s__("mrWidget|Email patches") }} + </a> + </li> + <li> + <a + :href="mr.plainDiffPath" + class="js-download-plain-diff" + download + > + {{ s__("mrWidget|Plain diff") }} + </a> + </li> + </ul> + </span> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 2f0b5e12c12..4a3fd01fa39 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -26,6 +26,10 @@ export default { type: String, required: false, }, + sourceBranchLink: { + type: String, + required: false, + }, }, computed: { hasPipeline() { @@ -54,12 +58,18 @@ export default { <template> <div v-if="hasPipeline || hasCIError" - class="mr-widget-heading" + class="mr-widget-heading append-bottom-default" > <div class="ci-widget media"> <template v-if="hasCIError"> - <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-10"> - <icon name="status_failed" /> + <div + class="add-border ci-status-icon ci-status-icon-failed ci-error + js-ci-error append-right-default" + > + <icon + :size="32" + name="status_failed_borderless" + /> </div> <div class="media-body"> Could not connect to the CI server. Please check your settings and try again @@ -68,50 +78,66 @@ export default { <template v-else-if="hasPipeline"> <a :href="status.details_path" - class="append-right-10" + class="align-self-start append-right-default" > - <ci-icon :status="status" /> + <ci-icon + :status="status" + :size="32" + :borderless="true" + class="add-border" + /> </a> + <div class="ci-widget-container d-flex"> + <div class="ci-widget-content"> + <div class="media-body"> + <div class="font-weight-bold"> + Pipeline + <a + :href="pipeline.path" + class="pipeline-id font-weight-normal pipeline-number" + >#{{ pipeline.id }}</a> - <div class="media-body"> - Pipeline - <a - :href="pipeline.path" - class="pipeline-id" - > - #{{ pipeline.id }} - </a> - - {{ pipeline.details.status.label }} + {{ pipeline.details.status.label }} - <template v-if="hasCommitInfo"> - for - - <a - :href="pipeline.commit.commit_path" - class="commit-sha js-commit-link" - > - {{ pipeline.commit.short_id }}</a>. - </template> - - <span class="mr-widget-pipeline-graph"> - <span - v-if="hasStages" - class="stage-cell" - > + <template v-if="hasCommitInfo"> + for + <a + :href="pipeline.commit.commit_path" + class="commit-sha js-commit-link font-weight-normal" + > + {{ pipeline.commit.short_id }}</a> + on + <span + class="label-branch" + v-html="sourceBranchLink" + > + </span> + </template> + </div> <div - v-for="(stage, i) in pipeline.details.stages" - :key="i" - class="stage-container dropdown js-mini-pipeline-graph" + v-if="pipeline.coverage" + class="coverage" > - <pipeline-stage :stage="stage" /> + Coverage {{ pipeline.coverage }}% </div> + </div> + </div> + <div> + <span class="mr-widget-pipeline-graph"> + <span + v-if="hasStages" + class="stage-cell" + > + <div + v-for="(stage, i) in pipeline.details.stages" + :key="i" + class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" + > + <pipeline-stage :stage="stage" /> + </div> + </span> </span> - </span> - - <template v-if="pipeline.coverage"> - Coverage {{ pipeline.coverage }}% - </template> + </div> </div> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 53c4dc8c8f4..55b87f3a8ec 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -43,6 +43,7 @@ <ci-icon v-else :status="statusObj" + :size="24" /> <button diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 09477da40b5..b5de3dd6d73 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -252,41 +252,44 @@ export default { :pipeline="mr.pipeline" :ci-status="mr.ciStatus" :has-ci="mr.hasCI" + :source-branch-link="mr.sourceBranchLink" /> <deployment v-for="deployment in mr.deployments" :key="deployment.id" :deployment="deployment" /> - <div class="mr-widget-section"> - <component - :is="componentName" - :mr="mr" - :service="service" - /> + <div class="mr-section-container"> + <div class="mr-widget-section"> + <component + :is="componentName" + :mr="mr" + :service="service" + /> - <section - v-if="mr.allowCollaboration" - class="mr-info-list mr-links" - > - {{ s__("mrWidget|Allows commits from members who can merge to the target branch") }} - </section> + <section + v-if="mr.allowCollaboration" + class="mr-info-list mr-links" + > + {{ s__("mrWidget|Allows commits from members who can merge to the target branch") }} + </section> - <mr-widget-related-links - v-if="shouldRenderRelatedLinks" - :state="mr.state" - :related-links="mr.relatedLinks" - /> + <mr-widget-related-links + v-if="shouldRenderRelatedLinks" + :state="mr.state" + :related-links="mr.relatedLinks" + /> - <source-branch-removal-status - v-if="shouldRenderSourceBranchRemovalStatus" - /> - </div> - <div - v-if="shouldRenderMergeHelp" - class="mr-widget-footer" - > - <mr-widget-merge-help /> + <source-branch-removal-status + v-if="shouldRenderSourceBranchRemovalStatus" + /> + </div> + <div + v-if="shouldRenderMergeHelp" + class="mr-widget-footer" + > + <mr-widget-merge-help /> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 298971a36b2..d62537021ca 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -1,5 +1,6 @@ <script> import $ from 'jquery'; + import { s__ } from '~/locale'; import Flash from '../../../flash'; import GLForm from '../../../gl_form'; import markdownHeader from './header.vue'; @@ -22,6 +23,11 @@ type: String, required: true, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, addSpacingClasses: { type: Boolean, required: false, @@ -92,10 +98,11 @@ if (text) { this.markdownPreviewLoading = true; - this.$http.post(this.markdownPreviewPath, { text }) - .then(resp => resp.json()) - .then(data => this.renderMarkdown(data)) - .catch(() => new Flash('Error loading markdown preview')); + this.$http + .post(this.versionedPreviewPath(), { text }) + .then(resp => resp.json()) + .then(data => this.renderMarkdown(data)) + .catch(() => new Flash(s__('Error loading markdown preview'))); } else { this.renderMarkdown(); } @@ -119,6 +126,13 @@ $(this.$refs['markdown-preview']).renderGFM(); }); }, + + versionedPreviewPath() { + const { markdownPreviewPath, markdownVersion } = this; + return `${markdownPreviewPath}${ + markdownPreviewPath.indexOf('?') === -1 ? '?' : '&' + }markdown_version=${markdownVersion}`; + }, }, }; </script> diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 74475daae14..c7b5e22c33d 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -36,7 +36,7 @@ width: 100%; } - &.projects-dropdown-menu { + &.frequent-items-dropdown-menu { padding: 0; overflow-y: initial; max-height: initial; @@ -790,6 +790,7 @@ @include media-breakpoint-down(xs) { .navbar-gitlab { li.header-projects, + li.header-groups, li.header-more, li.header-new, li.header-user { @@ -813,18 +814,18 @@ } } -header.header-content .dropdown-menu.projects-dropdown-menu { +header.header-content .dropdown-menu.frequent-items-dropdown-menu { padding: 0; } -.projects-dropdown-container { +.frequent-items-dropdown-container { display: flex; flex-direction: row; width: 500px; height: 334px; - .project-dropdown-sidebar, - .project-dropdown-content { + .frequent-items-dropdown-sidebar, + .frequent-items-dropdown-content { padding: 8px 0; } @@ -832,12 +833,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu { color: $almost-black; } - .project-dropdown-sidebar { + .frequent-items-dropdown-sidebar { width: 30%; border-right: 1px solid $border-color; } - .project-dropdown-content { + .frequent-items-dropdown-content { position: relative; width: 70%; } @@ -848,33 +849,35 @@ header.header-content .dropdown-menu.projects-dropdown-menu { height: auto; flex: 1; - .project-dropdown-sidebar, - .project-dropdown-content { + .frequent-items-dropdown-sidebar, + .frequent-items-dropdown-content { width: 100%; } - .project-dropdown-sidebar { + .frequent-items-dropdown-sidebar { border-bottom: 1px solid $border-color; border-right: 0; } } - .projects-list-frequent-container, - .projects-list-search-container { + .section-header, + .frequent-items-list-container li.section-empty { + padding: 0 $gl-padding; + color: $gl-text-color-secondary; + font-size: $gl-font-size; + } + + .frequent-items-list-container { padding: 8px 0; overflow-y: auto; li.section-empty.section-failure { color: $callout-danger-color; } - } - .section-header, - .projects-list-frequent-container li.section-empty, - .projects-list-search-container li.section-empty { - padding: 0 15px; - color: $gl-text-color-secondary; - font-size: $gl-font-size; + .frequent-items-list-item-container a { + display: flex; + } } .search-input-container { @@ -894,12 +897,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu { margin-top: 8px; } - .projects-list-search-container { + .frequent-items-search-container { height: 284px; } @include media-breakpoint-down(xs) { - .projects-list-frequent-container { + .frequent-items-list-container { width: auto; height: auto; padding-bottom: 0; @@ -907,32 +910,38 @@ header.header-content .dropdown-menu.projects-dropdown-menu { } } -.projects-list-item-container { - .project-item-avatar-container .project-item-metadata-container { +.frequent-items-list-item-container { + .frequent-items-item-avatar-container, + .frequent-items-item-metadata-container { float: left; } - .project-title, - .project-namespace { + .frequent-items-item-metadata-container { + display: flex; + flex-direction: column; + justify-content: center; + } + + .frequent-items-item-title, + .frequent-items-item-namespace { max-width: 250px; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } &:hover { - .project-item-avatar-container .avatar { + .frequent-items-item-avatar-container .avatar { border-color: $md-area-border; } } - .project-title { + .frequent-items-item-title { font-size: $gl-font-size; font-weight: 400; line-height: 16px; } - .project-namespace { + .frequent-items-item-namespace { margin-top: 4px; font-size: 12px; line-height: 12px; @@ -940,7 +949,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu { } @include media-breakpoint-down(xs) { - .project-item-metadata-container { + .frequent-items-item-metadata-container { float: none; } } diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 282e424fc38..a22454c24e2 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -255,3 +255,8 @@ label { color: $theme-gray-600; } } + +.input-lg { + max-width: 320px; + width: 100%; +} diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index aaa8bed3df0..dff6bce370f 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -29,15 +29,21 @@ .navbar-sub-nav, .navbar-nav { > li { - > a:hover, - > a:focus { - background-color: rgba($search-and-nav-links, 0.2); + > a, + > button { + &:hover, + &:focus { + background-color: rgba($search-and-nav-links, 0.2); + } } - &.active > a, - &.dropdown.show > a { - color: $nav-svg-color; - background-color: $color-alternate; + &.active, + &.dropdown.show { + > a, + > button { + color: $nav-svg-color; + background-color: $color-alternate; + } } &.line-separator { @@ -147,7 +153,6 @@ } } - // Sidebar .nav-sidebar li.active { box-shadow: inset 4px 0 0 $border-and-box-shadow; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 8bcaf5eb6ac..2097bcebf69 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -269,14 +269,8 @@ .navbar-sub-nav, .navbar-nav { > li { - > a:hover, - > a:focus { - text-decoration: none; - outline: 0; - color: $white-light; - } - - > a { + > a, + > button { display: -webkit-flex; display: flex; align-items: center; @@ -288,6 +282,18 @@ border-radius: $border-radius-default; height: 32px; font-weight: $gl-font-weight-bold; + + &:hover, + &:focus { + text-decoration: none; + outline: 0; + color: $white-light; + } + } + + > button { + background: transparent; + border: 0; } &.line-separator { @@ -311,7 +317,7 @@ font-size: 10px; } - .project-item-select-holder { + .frequent-items-item-select-holder { display: inline; } diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 30314f3d6cb..d1f7ff4438b 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -3,12 +3,20 @@ svg { fill: $green-500; } + + &.add-border { + @include borderless-status-icon($green-500); + } } .ci-status-icon-failed { svg { fill: $gl-danger; } + + &.add-border { + @include borderless-status-icon($red-500); + } } .ci-status-icon-pending, @@ -17,12 +25,20 @@ svg { fill: $orange-500; } + + &.add-border { + @include borderless-status-icon($orange-500); + } } .ci-status-icon-running { svg { fill: $blue-400; } + + &.add-border { + @include borderless-status-icon($blue-400); + } } .ci-status-icon-canceled, @@ -30,6 +46,10 @@ svg { fill: $gl-text-color; } + + &.add-border { + @include borderless-status-icon($gl-text-color); + } } .ci-status-icon-created, @@ -38,6 +58,10 @@ svg { fill: $gray-darkest; } + + &.add-border { + @include borderless-status-icon($gray-darkest); + } } .ci-status-icon-manual { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 0b645eb811b..76ebfc22ef7 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -232,3 +232,10 @@ word-break: break-word; max-width: 100%; } + +@mixin borderless-status-icon($color) { + svg { + border: 1px solid $color; + border-radius: 50%; + } +} diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 9e77ea03a24..9874c928604 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -350,7 +350,8 @@ code { } .commit-sha, -.ref-name { +.ref-name, +.pipeline-number { @extend .monospace; font-size: 95%; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 7808f6d3a25..6cfa09b56a7 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -743,6 +743,7 @@ Pipeline Graph */ $stage-hover-bg: $gray-darker; $ci-action-icon-size: 22px; +$ci-action-icon-size-lg: 24px; $pipeline-dropdown-line-height: 20px; $pipeline-dropdown-status-icon-size: 18px; $ci-action-dropdown-button-size: 24px; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 750d2c8b990..5de53892fac 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -80,7 +80,6 @@ overflow-x: scroll; white-space: nowrap; min-height: 200px; - display: flex; @include media-breakpoint-only(sm) { height: calc(100vh - #{$issue-board-list-difference-sm}); @@ -111,15 +110,17 @@ .board { display: inline-block; - flex: 1; - min-width: 300px; - max-width: 400px; + width: calc(85vw - 15px); height: 100%; padding-right: ($gl-padding / 2); padding-left: ($gl-padding / 2); white-space: normal; vertical-align: top; + @include media-breakpoint-up(sm) { + width: 400px; + } + &.is-expandable { .board-header { cursor: pointer; @@ -127,8 +128,6 @@ } &.is-collapsed { - flex: none; - min-width: 0; width: 50px; .board-header { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index efd730af558..c32049e1b33 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -15,16 +15,38 @@ } } +.mr-widget-heading { + position: relative; + border: 1px solid $border-color; + border-radius: 4px; + + &:not(.deploy-heading)::before { + content: ''; + border-left: 1px solid $theme-gray-200; + position: absolute; + left: 32px; + top: -17px; + height: 16px; + } +} + +.mr-section-container { + border: 1px solid $border-color; + border-radius: $border-radius-default; + border-top: 0; +} + +.mr-widget-heading, +.mr-widget-section, +.mr-widget-footer { + padding: $gl-padding; +} + .mr-state-widget { color: $gl-text-color; - border: 1px solid $border-color; - border-radius: 2px; - line-height: 28px; - .mr-widget-heading, .mr-widget-section, .mr-widget-footer { - padding: $gl-padding; border-top: solid 1px $border-color; } @@ -124,10 +146,17 @@ .ci-widget { color: $gl-text-color; display: flex; + align-items: center; + justify-content: space-between; @include media-breakpoint-down(xs) { flex-wrap: wrap; } + + .ci-widget-content { + display: flex; + align-items: center; + } } .mr-widget-icon { @@ -136,8 +165,6 @@ } .ci-status-icon svg { - width: $status-icon-size; - height: $status-icon-size; margin: 3px 0; position: relative; overflow: visible; @@ -145,8 +172,6 @@ } .mr-widget-pipeline-graph { - padding: 0 4px; - .dropdown-menu { z-index: 300; } @@ -157,7 +182,7 @@ } .normal { - line-height: 28px; + flex: 1; } .capitalize { @@ -168,7 +193,7 @@ @extend .ref-name; color: $gl-text-color; - font-weight: $gl-font-weight-bold; + font-weight: normal; overflow: hidden; word-break: break-all; @@ -192,6 +217,8 @@ } .mr-widget-body { + line-height: 28px; + @include clearfix; &.media > *:first-child { @@ -474,18 +501,66 @@ } } +.merge-request-details .content-block { + border-bottom: 0; +} + .mr-source-target { display: flex; flex-wrap: wrap; - justify-content: space-between; - align-items: center; - background-color: $gray-light; - border-radius: $border-radius-default $border-radius-default 0 0; - padding: $gl-padding / 2 $gl-padding; + border-radius: $border-radius-default; + padding: $gl-padding; + border: 1px solid $border-color; + min-height: 69px; + + @include media-breakpoint-up(md) { + align-items: center; + } .dropdown-toggle .fa { color: $gl-text-color; } + + .git-merge-icon-container { + border: 1px solid $theme-gray-400; + border-radius: 50%; + height: 32px; + width: 32px; + color: $theme-gray-700; + line-height: 28px; + + .ic-git-merge { + vertical-align: middle; + width: 31px; + } + } + + .git-merge-container { + justify-content: space-between; + flex: 1; + flex-direction: row; + align-items: center; + + @include media-breakpoint-down(md) { + flex-direction: column; + align-items: flex-start; + + .branch-actions { + margin-top: 16px; + } + } + + @include media-breakpoint-up(lg) { + .branch-actions { + align-self: center; + } + } + } + + .diverged-commits-count { + color: $gl-text-color-secondary; + font-size: 12px; + } } .card-new-merge-request { @@ -720,13 +795,25 @@ } .deploy-heading { + margin-top: -19px; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-color: $gray-light; + + @include media-breakpoint-up(md) { + padding: $gl-padding-8 $gl-padding; + } + .media-body { min-width: 0; + font-size: 12px; + margin-left: 48px; } } .deploy-body { display: flex; + align-items: center; flex-wrap: wrap; @include media-breakpoint-up(xs) { @@ -734,6 +821,15 @@ white-space: nowrap; } + @include media-breakpoint-down(md) { + flex-direction: column; + align-items: flex-start; + + .deployment-info { + margin-bottom: $gl-padding; + } + } + > *:not(:last-child) { margin-right: .3em; } @@ -741,18 +837,22 @@ svg { vertical-align: text-top; } -} -.deploy-link { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 100px; - max-width: 150px; + .deployment-info { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 100px; - @include media-breakpoint-up(xs) { - min-width: 0; - max-width: 100%; + @include media-breakpoint-up(xs) { + min-width: 0; + max-width: 100%; + } + } + + .btn svg { + fill: $theme-gray-700; } } @@ -772,3 +872,33 @@ } } } + +.ci-widget-container { + justify-content: space-between; + flex: 1; + flex-direction: row; + + @include media-breakpoint-down(md) { + flex-direction: column; + + .stage-cell .stage-container { + margin-top: 16px; + } + + .dropdown .mini-pipeline-graph-dropdown-menu.dropdown-menu { + transform: initial; + } + } + + .coverage { + font-size: 12px; + color: $theme-gray-700; + line-height: initial; + } + + .mini-pipeline-graph-dropdown-toggle, + .stage-cell .mini-pipeline-graph-dropdown-toggle svg { + height: $ci-action-icon-size-lg; + width: $ci-action-icon-size-lg; + } +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 52332ac97dd..b68c89c25d8 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -301,6 +301,21 @@ border-bottom: 2px solid $border-color; } } + + //delete when all pipelines are updated to new size + &.mr-widget-pipeline-stages { + + .stage-container { + margin-left: 4px; + } + + &:not(:last-child) { + &::after { + width: 4px; + right: -4px; + top: 11px; + } + } + } } } diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 3c24aaa65e8..6e2b285285a 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -1329,3 +1329,14 @@ line-height: 16px; color: $gl-text-color-secondary; } + +.ide-merge-request-info { + .detail-page-header { + line-height: initial; + min-height: 38px; + } + + .issuable-details { + overflow: auto; + } +} diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb index 56770a17406..6ec6897e707 100644 --- a/app/controllers/concerns/group_tree.rb +++ b/app/controllers/concerns/group_tree.rb @@ -1,21 +1,16 @@ module GroupTree # rubocop:disable Gitlab/ModuleWithInstanceVariables def render_group_tree(groups) - @groups = if params[:filter].present? - # We find the ancestors by ID of the search results here. - # Otherwise the ancestors would also have filters applied, - # which would cause them not to be preloaded. - group_ids = groups.search(params[:filter]).select(:id) - Gitlab::GroupHierarchy.new(Group.where(id: group_ids)) - .base_and_ancestors - else - # Only show root groups if no parent-id is given - groups.where(parent_id: params[:parent_id]) - end + groups = groups.sort_by_attribute(@sort = params[:sort]) - @groups = @groups.with_selects_for_list(archived: params[:archived]) - .sort_by_attribute(@sort = params[:sort]) - .page(params[:page]) + groups = if params[:filter].present? + filtered_groups_with_ancestors(groups) + else + # If `params[:parent_id]` is `nil`, we will only show root-groups + groups.where(parent_id: params[:parent_id]).page(params[:page]) + end + + @groups = groups.with_selects_for_list(archived: params[:archived]) respond_to do |format| format.html @@ -28,4 +23,21 @@ module GroupTree end # rubocop:enable Gitlab/ModuleWithInstanceVariables end + + def filtered_groups_with_ancestors(groups) + filtered_groups = groups.search(params[:filter]).page(params[:page]) + + if Group.supports_nested_groups? + # We find the ancestors by ID of the search results here. + # Otherwise the ancestors would also have filters applied, + # which would cause them not to be preloaded. + # + # Pagination needs to be applied before loading the ancestors to + # make sure ancestors are not cut off by pagination. + Gitlab::GroupHierarchy.new(Group.where(id: filtered_groups.select(:id))) + .base_and_ancestors + else + filtered_groups + end + end end diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 7ac63c914fa..99123fcb3b0 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -14,6 +14,8 @@ module PreviewMarkdown else {} end + markdown_params[:markdown_engine] = result[:markdown_engine] + render json: { body: view_context.markdown(result[:text], markdown_params), references: { diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 992c8ea6992..07627ffb69f 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -14,7 +14,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController end def labels - render json: @autocomplete_service.labels(target) + render json: @autocomplete_service.labels_as_hash(target) end def milestones diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index ec3a5788ba1..f2abe27f60e 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -2,6 +2,7 @@ class ProjectsController < Projects::ApplicationController include IssuableCollections include ExtractsPath include PreviewMarkdown + include SendFileUpload before_action :whitelist_query_limiting, only: [:create] before_action :authenticate_user!, except: [:index, :show, :activity, :refs] @@ -188,9 +189,9 @@ class ProjectsController < Projects::ApplicationController end def download_export - export_project_path = @project.export_project_path - - if export_project_path + if export_project_object_storage? + send_upload(@project.import_export_upload.export_file) + elsif export_project_path send_file export_project_path, disposition: 'attachment' else redirect_to( @@ -265,8 +266,6 @@ class ProjectsController < Projects::ApplicationController render json: options.to_json end - private - # Render project landing depending of which features are available # So if page is not availble in the list it renders the next page # @@ -424,4 +423,12 @@ class ProjectsController < Projects::ApplicationController def whitelist_query_limiting Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42440') end + + def export_project_path + @export_project_path ||= @project.export_project_path + end + + def export_project_object_storage? + @project.export_project_object_exists? + end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 95fea2f18d1..3c5c8bbd71b 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -128,8 +128,10 @@ module GroupsHelper def get_group_sidebar_links links = [:overview, :group_members] - if can?(current_user, :read_cross_project) - links += [:activity, :issues, :boards, :labels, :milestones, :merge_requests] + resources = [:activity, :issues, :boards, :labels, :milestones, + :merge_requests] + links += resources.select do |resource| + can?(current_user, "read_group_#{resource}".to_sym, @group) end if can?(current_user, :admin_group, @group) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 353479776b8..7bbdc798ddd 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -249,6 +249,7 @@ module IssuablesHelper issuableRef: issuable.to_reference, markdownPreviewPath: preview_markdown_path(parent), markdownDocsPath: help_page_path('user/markdown'), + markdownVersion: issuable.cached_markdown_version, issuableTemplates: issuable_templates(issuable), initialTitleHtml: markdown_field(issuable, :title), initialTitleText: issuable.title, diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 39e7a7fd396..cbb971cf8b7 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -107,6 +107,7 @@ module MarkupHelper def markup(file_name, text, context = {}) context[:project] ||= @project + context[:markdown_engine] ||= :redcarpet html = context.delete(:rendered) || markup_unsafe(file_name, text, context) prepare_for_rendering(html, context) end @@ -120,7 +121,8 @@ module MarkupHelper project: @project, project_wiki: @project_wiki, page_slug: wiki_page.slug, - issuable_state_filter_enabled: true + issuable_state_filter_enabled: true, + markdown_engine: :redcarpet } html = diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 3fa2e5452c8..5404ead44f3 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -169,6 +169,7 @@ module NotesHelper registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'), markdownDocsPath: help_page_path('user/markdown'), + markdownVersion: issuable.cached_markdown_version, quickActionsDocsPath: help_page_path('user/project/quick_actions'), closePath: close_issuable_path(issuable), reopenPath: reopen_issuable_path(issuable), diff --git a/spec/mailers/previews/devise_mailer_preview.rb b/app/mailers/previews/devise_mailer_preview.rb index d6588efc486..d6588efc486 100644 --- a/spec/mailers/previews/devise_mailer_preview.rb +++ b/app/mailers/previews/devise_mailer_preview.rb diff --git a/spec/mailers/previews/email_rejection_mailer_preview.rb b/app/mailers/previews/email_rejection_mailer_preview.rb index 639e8471232..639e8471232 100644 --- a/spec/mailers/previews/email_rejection_mailer_preview.rb +++ b/app/mailers/previews/email_rejection_mailer_preview.rb diff --git a/spec/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index e32fd0bd120..3615cde8026 100644 --- a/spec/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -153,7 +153,7 @@ class NotifyPreview < ActionMailer::Preview cleanup do note = yield - Notify.public_send(method, user.id, note) + Notify.public_send(method, user.id, note) # rubocop:disable GitlabSecurity/PublicSend end end diff --git a/spec/mailers/previews/repository_check_mailer_preview.rb b/app/mailers/previews/repository_check_mailer_preview.rb index 19d4eab1805..19d4eab1805 100644 --- a/spec/mailers/previews/repository_check_mailer_preview.rb +++ b/app/mailers/previews/repository_check_mailer_preview.rb diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 4856f10846c..b442de34061 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -1,54 +1,58 @@ module Ci class BuildTraceChunk < ActiveRecord::Base include FastDestroyAll + include ::Gitlab::ExclusiveLeaseHelpers extend Gitlab::Ci::Model belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id default_value_for :data_store, :redis - WriteError = Class.new(StandardError) - CHUNK_SIZE = 128.kilobytes - CHUNK_REDIS_TTL = 1.week WRITE_LOCK_RETRY = 10 WRITE_LOCK_SLEEP = 0.01.seconds WRITE_LOCK_TTL = 1.minute + # Note: The ordering of this enum is related to the precedence of persist store. + # The bottom item takes the higest precedence, and the top item takes the lowest precedence. enum data_store: { redis: 1, - db: 2 + database: 2, + fog: 3 } class << self - def redis_data_key(build_id, chunk_index) - "gitlab:ci:trace:#{build_id}:chunks:#{chunk_index}" + def all_stores + @all_stores ||= self.data_stores.keys end - def redis_data_keys - redis.pluck(:build_id, :chunk_index).map do |data| - redis_data_key(data.first, data.second) - end + def persistable_store + # get first available store from the back of the list + all_stores.reverse.find { |store| get_store_class(store).available? } end - def redis_delete_data(keys) - return if keys.empty? - - Gitlab::Redis::SharedState.with do |redis| - redis.del(keys) - end + def get_store_class(store) + @stores ||= {} + @stores[store] ||= "Ci::BuildTraceChunks::#{store.capitalize}".constantize.new end ## # FastDestroyAll concerns def begin_fast_destroy - redis_data_keys + all_stores.each_with_object({}) do |store, result| + relation = public_send(store) # rubocop:disable GitlabSecurity/PublicSend + keys = get_store_class(store).keys(relation) + + result[store] = keys if keys.present? + end end ## # FastDestroyAll concerns def finalize_fast_destroy(keys) - redis_delete_data(keys) + keys.each do |store, value| + get_store_class(store).delete_keys(value) + end end end @@ -66,10 +70,15 @@ module Ci end def append(new_data, offset) + raise ArgumentError, 'New data is missing' unless new_data raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0 raise ArgumentError, 'Chunk size overflow' if CHUNK_SIZE < (offset + new_data.bytesize) - set_data(data.byteslice(0, offset) + new_data) + in_lock(*lock_params) do # Write opetation is atomic + unsafe_set_data!(data.byteslice(0, offset) + new_data) + end + + schedule_to_persist if full? end def size @@ -88,93 +97,63 @@ module Ci (start_offset...end_offset) end - def use_database! - in_lock do - break if db? - break unless size > 0 - - self.update!(raw_data: data, data_store: :db) - self.class.redis_delete_data([redis_data_key]) + def persist_data! + in_lock(*lock_params) do # Write opetation is atomic + unsafe_persist_to!(self.class.persistable_store) end end private - def get_data - if redis? - redis_data - elsif db? - raw_data - else - raise 'Unsupported data store' - end&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default - end - - def set_data(value) - raise ArgumentError, 'too much data' if value.bytesize > CHUNK_SIZE - - in_lock do - if redis? - redis_set_data(value) - elsif db? - self.raw_data = value - else - raise 'Unsupported data store' - end + def unsafe_persist_to!(new_store) + return if data_store == new_store.to_s + raise ArgumentError, 'Can not persist empty data' unless size > 0 - @data = value + old_store_class = self.class.get_store_class(data_store) - save! if changed? + get_data.tap do |the_data| + self.raw_data = nil + self.data_store = new_store + unsafe_set_data!(the_data) end - schedule_to_db if full? - end - - def schedule_to_db - return if db? - - Ci::BuildTraceChunkFlushWorker.perform_async(id) + old_store_class.delete_data(self) end - def full? - size == CHUNK_SIZE + def get_data + self.class.get_store_class(data_store).data(self)&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default + rescue Excon::Error::NotFound + # If the data store is :fog and the file does not exist in the object storage, this method returns nil. end - def redis_data - Gitlab::Redis::SharedState.with do |redis| - redis.get(redis_data_key) - end - end + def unsafe_set_data!(value) + raise ArgumentError, 'New data size exceeds chunk size' if value.bytesize > CHUNK_SIZE - def redis_set_data(data) - Gitlab::Redis::SharedState.with do |redis| - redis.set(redis_data_key, data, ex: CHUNK_REDIS_TTL) - end - end + self.class.get_store_class(data_store).set_data(self, value) + @data = value - def redis_data_key - self.class.redis_data_key(build_id, chunk_index) + save! if changed? end - def in_lock - write_lock_key = "trace_write:#{build_id}:chunks:#{chunk_index}" + def schedule_to_persist + return if data_persisted? - lease = Gitlab::ExclusiveLease.new(write_lock_key, timeout: WRITE_LOCK_TTL) - retry_count = 0 + Ci::BuildTraceChunkFlushWorker.perform_async(id) + end - until uuid = lease.try_obtain - # Keep trying until we obtain the lease. To prevent hammering Redis too - # much we'll wait for a bit between retries. - sleep(WRITE_LOCK_SLEEP) - break if WRITE_LOCK_RETRY < (retry_count += 1) - end + def data_persisted? + !redis? + end - raise WriteError, 'Failed to obtain write lock' unless uuid + def full? + size == CHUNK_SIZE + end - self.reload if self.persisted? - return yield - ensure - Gitlab::ExclusiveLease.cancel(write_lock_key, uuid) + def lock_params + ["trace_write:#{build_id}:chunks:#{chunk_index}", + { ttl: WRITE_LOCK_TTL, + retries: WRITE_LOCK_RETRY, + sleep_sec: WRITE_LOCK_SLEEP }] end end end diff --git a/app/models/ci/build_trace_chunks/database.rb b/app/models/ci/build_trace_chunks/database.rb new file mode 100644 index 00000000000..3666d77c790 --- /dev/null +++ b/app/models/ci/build_trace_chunks/database.rb @@ -0,0 +1,29 @@ +module Ci + module BuildTraceChunks + class Database + def available? + true + end + + def keys(relation) + [] + end + + def delete_keys(keys) + # no-op + end + + def data(model) + model.raw_data + end + + def set_data(model, data) + model.raw_data = data + end + + def delete_data(model) + model.update_columns(raw_data: nil) unless model.raw_data.nil? + end + end + end +end diff --git a/app/models/ci/build_trace_chunks/fog.rb b/app/models/ci/build_trace_chunks/fog.rb new file mode 100644 index 00000000000..7506c40a39d --- /dev/null +++ b/app/models/ci/build_trace_chunks/fog.rb @@ -0,0 +1,59 @@ +module Ci + module BuildTraceChunks + class Fog + def available? + object_store.enabled + end + + def data(model) + connection.get_object(bucket_name, key(model))[:body] + end + + def set_data(model, data) + connection.put_object(bucket_name, key(model), data) + end + + def delete_data(model) + delete_keys([[model.build_id, model.chunk_index]]) + end + + def keys(relation) + return [] unless available? + + relation.pluck(:build_id, :chunk_index) + end + + def delete_keys(keys) + keys.each do |key| + connection.delete_object(bucket_name, key_raw(*key)) + end + end + + private + + def key(model) + key_raw(model.build_id, model.chunk_index) + end + + def key_raw(build_id, chunk_index) + "tmp/builds/#{build_id.to_i}/chunks/#{chunk_index.to_i}.log" + end + + def bucket_name + return unless available? + + object_store.remote_directory + end + + def connection + return unless available? + + @connection ||= ::Fog::Storage.new(object_store.connection.to_hash.deep_symbolize_keys) + end + + def object_store + Gitlab.config.artifacts.object_store + end + end + end +end diff --git a/app/models/ci/build_trace_chunks/redis.rb b/app/models/ci/build_trace_chunks/redis.rb new file mode 100644 index 00000000000..fdb6065e2a0 --- /dev/null +++ b/app/models/ci/build_trace_chunks/redis.rb @@ -0,0 +1,51 @@ +module Ci + module BuildTraceChunks + class Redis + CHUNK_REDIS_TTL = 1.week + + def available? + true + end + + def data(model) + Gitlab::Redis::SharedState.with do |redis| + redis.get(key(model)) + end + end + + def set_data(model, data) + Gitlab::Redis::SharedState.with do |redis| + redis.set(key(model), data, ex: CHUNK_REDIS_TTL) + end + end + + def delete_data(model) + delete_keys([[model.build_id, model.chunk_index]]) + end + + def keys(relation) + relation.pluck(:build_id, :chunk_index) + end + + def delete_keys(keys) + return if keys.empty? + + keys = keys.map { |key| key_raw(*key) } + + Gitlab::Redis::SharedState.with do |redis| + redis.del(keys) + end + end + + private + + def key(model) + key_raw(model.build_id, model.chunk_index) + end + + def key_raw(build_id, chunk_index) + "gitlab:ci:trace:#{build_id.to_i}:chunks:#{chunk_index.to_i}" + end + end + end +end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 9f6358cecbe..b05bf909058 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -40,6 +40,18 @@ module CacheMarkdownField end end + class MarkdownEngine + def self.from_version(version = nil) + return :common_mark if version.nil? || version == 0 + + if version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START + :redcarpet + else + :common_mark + end + end + end + def skip_project_check? false end @@ -57,7 +69,7 @@ module CacheMarkdownField # Banzai is less strict about authors, so don't always have an author key context[:author] = self.author if self.respond_to?(:author) - context[:markdown_engine] = markdown_engine + context[:markdown_engine] = MarkdownEngine.from_version(latest_cached_markdown_version) context end @@ -123,14 +135,6 @@ module CacheMarkdownField end end - def markdown_engine - if latest_cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - :redcarpet - else - :common_mark - end - end - included do cattr_reader :cached_markdown_fields do FieldData.new diff --git a/app/models/concerns/cacheable_attributes.rb b/app/models/concerns/cacheable_attributes.rb index d58d7165969..606549b947f 100644 --- a/app/models/concerns/cacheable_attributes.rb +++ b/app/models/concerns/cacheable_attributes.rb @@ -7,7 +7,7 @@ module CacheableAttributes class_methods do def cache_key - "#{name}:#{Gitlab::VERSION}:#{Gitlab.migrations_hash}:#{Rails.version}".freeze + "#{name}:#{Gitlab::VERSION}:#{Rails.version}".freeze end # Can be overriden @@ -69,6 +69,6 @@ module CacheableAttributes end def cache! - Rails.cache.write(self.class.cache_key, self) + Rails.cache.write(self.class.cache_key, self, expires_in: 1.minute) end end diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb index 261ace57a17..5e9a95c3282 100644 --- a/app/models/concerns/group_descendant.rb +++ b/app/models/concerns/group_descendant.rb @@ -44,8 +44,8 @@ module GroupDescendant This error is not user facing, but causes a +1 query. MSG extras = { - parent: parent, - child: child, + parent: parent.inspect, + child: child.inspect, preloaded: preloaded.map(&:full_path) } issue_url = 'https://gitlab.com/gitlab-org/gitlab-ce/issues/40785' diff --git a/app/models/import_export_upload.rb b/app/models/import_export_upload.rb new file mode 100644 index 00000000000..60d53d6c2c8 --- /dev/null +++ b/app/models/import_export_upload.rb @@ -0,0 +1,13 @@ +class ImportExportUpload < ActiveRecord::Base + include WithUploads + include ObjectStorage::BackgroundMove + + belongs_to :project + + mount_uploader :import_file, ImportExportUploader + mount_uploader :export_file, ImportExportUploader + + def retrieve_upload(_identifier, paths) + Upload.find_by(model: self, path: paths) + end +end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index d05dcfd083a..14cc12b38a5 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -131,9 +131,10 @@ class Milestone < ActiveRecord::Base rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id') else rel - .group(:project_id) + .group(:project_id, :due_date, :id) .having('due_date = MIN(due_date)') .pluck(:id, :project_id, :due_date) + .uniq(&:second) .map(&:first) end end diff --git a/app/models/project.rb b/app/models/project.rb index 8f40470de82..770262f6193 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -171,6 +171,7 @@ class Project < ActiveRecord::Base has_one :fork_network, through: :fork_network_member has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project + has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id' @@ -1712,7 +1713,7 @@ class Project < ActiveRecord::Base :started elsif after_export_in_progress? :after_export_action - elsif export_project_path + elsif export_project_path || export_project_object_exists? :finished else :none @@ -1727,16 +1728,21 @@ class Project < ActiveRecord::Base import_export_shared.after_export_in_progress? end - def remove_exports - return nil unless export_path.present? - - FileUtils.rm_rf(export_path) + def remove_exports(path = export_path) + if path.present? + FileUtils.rm_rf(path) + elsif export_project_object_exists? + import_export_upload.remove_export_file! + import_export_upload.save + end end def remove_exported_project_file - return unless export_project_path.present? + remove_exports(export_project_path) + end - FileUtils.rm_f(export_project_path) + def export_project_object_exists? + Gitlab::ImportExport.object_storage? && import_export_upload&.export_file&.file end def full_path_slug diff --git a/app/models/repository.rb b/app/models/repository.rb index 5f9894f1168..7cd600fec5b 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -83,7 +83,7 @@ class Repository @raw_repository&.cleanup end - # Return absolute path to repository + # Don't use this! It's going away. Use Gitaly to read or write from repos. def path_to_repo @path_to_repo ||= begin @@ -250,7 +250,7 @@ class Repository # This will still fail if the file is corrupted (e.g. 0 bytes) raw_repository.write_ref(keep_around_ref_name(sha), sha, shell: false) rescue Gitlab::Git::CommandError => ex - Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}" + Rails.logger.error "Unable to create keep-around reference for repository #{disk_path}: #{ex}" end def kept_around?(sha) @@ -564,7 +564,7 @@ class Repository end def rendered_readme - MarkupHelper.markup_unsafe(readme.name, readme.data, project: project) if readme + MarkupHelper.markup_unsafe(readme.name, readme.data, project: project, markdown_engine: :redcarpet) if readme end cache_method :rendered_readme diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 520710b757d..ded9fe30eff 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -72,6 +72,19 @@ class GroupPolicy < BasePolicy enable :change_visibility_level end + rule { can?(:read_nested_project_resources) }.policy do + enable :read_group_activity + enable :read_group_issues + enable :read_group_boards + enable :read_group_labels + enable :read_group_milestones + enable :read_group_merge_requests + end + + rule { can?(:read_cross_project) & can?(:read_group) }.policy do + enable :read_nested_project_resources + end + rule { owner & nested_groups_supported }.enable :create_subgroup rule { public_group | logged_in_viewable }.enable :view_globally diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index ce0c31b5806..0e1f94a9f61 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -62,6 +62,8 @@ class NoteEntity < API::Entities::Note expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? } + expose :cached_markdown_version + private def current_user diff --git a/app/services/import_export_clean_up_service.rb b/app/services/import_export_clean_up_service.rb index 74088b970c9..3702c3742ef 100644 --- a/app/services/import_export_clean_up_service.rb +++ b/app/services/import_export_clean_up_service.rb @@ -10,7 +10,9 @@ class ImportExportCleanUpService def execute Gitlab::Metrics.measure(:import_export_clean_up) do - next unless File.directory?(path) + clean_up_export_object_files + + break unless File.directory?(path) clean_up_export_files end @@ -21,4 +23,11 @@ class ImportExportCleanUpService def clean_up_export_files Gitlab::Popen.popen(%W(find #{path} -not -path #{path} -mmin +#{mmin} -delete)) end + + def clean_up_export_object_files + ImportExportUpload.where('updated_at < ?', mmin.minutes.ago).each do |upload| + upload.remove_export_file! + upload.save! + end + end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 636cfbf5b45..8c6221af788 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -135,6 +135,8 @@ class NotificationService # * watchers of the mr's labels # * users with custom level checked with "new merge request" # + # In EE, approvers of the merge request are also included + # def new_merge_request(merge_request, current_user) new_resource_email(merge_request, :new_merge_request_email) end @@ -256,6 +258,10 @@ class NotificationService # ignore gitlab service messages return true if note.cross_reference? && note.system? + send_new_note_notifications(note) + end + + def send_new_note_notifications(note) notify_method = "note_#{note.to_ability_name}_email".to_sym recipients = NotificationRecipientService.build_new_note_recipients(note) diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index 4ee2c1796bd..6da4d9523cf 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -6,7 +6,8 @@ class PreviewMarkdownService < BaseService success( text: text, users: users, - commands: commands.join(' ') + commands: commands.join(' '), + markdown_engine: markdown_engine ) end @@ -42,4 +43,8 @@ class PreviewMarkdownService < BaseService def commands_target_id params[:quick_actions_target_id] end + + def markdown_engine + CacheMarkdownField::MarkdownEngine.from_version(params[:markdown_version].to_i) + end end diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index aa60661f7f2..9d0eaaf3152 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -20,24 +20,28 @@ module Projects MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title]) end - def labels(target = nil) - labels = LabelsFinder.new(current_user, project_id: project.id, include_ancestor_groups: true) - .execute.select([:color, :title]) - - return labels unless target&.respond_to?(:labels) - - issuable_label_titles = target.labels.pluck(:title) - - if issuable_label_titles - labels = labels.as_json(only: [:title, :color]) - - issuable_label_titles.each do |issuable_label_title| - found_label = labels.find { |label| label['title'] == issuable_label_title } - found_label[:set] = true if found_label + def labels_as_hash(target = nil) + available_labels = LabelsFinder.new( + current_user, + project_id: project.id, + include_ancestor_groups: true + ).execute + + label_hashes = available_labels.as_json(only: [:title, :color]) + + if target&.respond_to?(:labels) + already_set_labels = available_labels & target.labels + if already_set_labels.present? + titles = already_set_labels.map(&:title) + label_hashes.each do |hash| + if titles.include?(hash['title']) + hash[:set] = true + end + end end end - labels + label_hashes end def commands(noteable, type) diff --git a/app/uploaders/import_export_uploader.rb b/app/uploaders/import_export_uploader.rb new file mode 100644 index 00000000000..213ac5c8011 --- /dev/null +++ b/app/uploaders/import_export_uploader.rb @@ -0,0 +1,15 @@ +class ImportExportUploader < AttachmentUploader + EXTENSION_WHITELIST = %w[tar.gz].freeze + + def extension_whitelist + EXTENSION_WHITELIST + end + + def move_to_store + true + end + + def move_to_cache + false + end +end diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index f7094375023..5e7be5cd37b 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -40,7 +40,7 @@ = project.human_import_status_name - @repos.each do |repo| - %tr{ id: "repo_#{repo.id}" } + %tr{ id: "repo_#{repo.id}", data: { qa: { repo_path: repo.full_name } } } %td = provider_project_link(provider, repo.full_name) %td.import-target @@ -50,7 +50,7 @@ - if current_user.can_select_namespace? - selected = params[:namespace_id] || :current_user - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner.login, path: repo.owner.login) } : {} - = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 } + = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace qa-project-namespace-select', tabindex: 1 } - else = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true %span.input-group-prepend diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index d35df706036..792291bde75 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -37,7 +37,7 @@ %li.dropdown-bold-header GitLab - if current_user.can_create_project? %li - = link_to 'New project', new_project_path + = link_to 'New project', new_project_path, class: 'qa-global-new-project-link' - if current_user.can_create_group? %li = link_to 'New group', new_group_path diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 7647e25e804..4029287fc0e 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -1,16 +1,19 @@ %ul.list-unstyled.navbar-sub-nav - if dashboard_nav_link?(:projects) = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do - %a{ href: "#", data: { toggle: "dropdown" } } + %button{ type: 'button', data: { toggle: "dropdown" } } Projects = sprite_icon('angle-down', css_class: 'caret-down') - .dropdown-menu.projects-dropdown-menu + .dropdown-menu.frequent-items-dropdown-menu = render "layouts/nav/projects_dropdown/show" - if dashboard_nav_link?(:groups) - = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "d-none d-sm-block" }) do - = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups qa-groups-link', title: 'Groups' do + = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown" }) do + %button{ type: 'button', data: { toggle: "dropdown" } } Groups + = sprite_icon('angle-down', css_class: 'caret-down') + .dropdown-menu.frequent-items-dropdown-menu + = render "layouts/nav/groups_dropdown/show" - if dashboard_nav_link?(:activity) = nav_link(path: 'dashboard#activity', html_options: { class: "d-none d-lg-block d-xl-block" }) do @@ -34,11 +37,6 @@ = sprite_icon('angle-down', css_class: 'caret-down') .dropdown-menu %ul - - if dashboard_nav_link?(:groups) - = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "d-block d-sm-none" }) do - = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do - Groups - - if dashboard_nav_link?(:activity) = nav_link(path: 'dashboard#activity') do = link_to activity_dashboard_path, title: 'Activity' do diff --git a/app/views/layouts/nav/groups_dropdown/_show.html.haml b/app/views/layouts/nav/groups_dropdown/_show.html.haml new file mode 100644 index 00000000000..3ce1fa6bcca --- /dev/null +++ b/app/views/layouts/nav/groups_dropdown/_show.html.haml @@ -0,0 +1,12 @@ +- group_meta = { id: @group.id, name: @group.name, namespace: @group.full_name, web_url: group_path(@group), avatar_url: @group.avatar_url } if @group&.persisted? +.frequent-items-dropdown-container + .frequent-items-dropdown-sidebar.qa-groups-dropdown-sidebar + %ul + = nav_link(path: 'dashboard/groups#index') do + = link_to dashboard_groups_path, class: 'qa-your-groups-link' do + = _('Your groups') + = nav_link(path: 'groups#explore') do + = link_to explore_groups_path do + = _('Explore groups') + .frequent-items-dropdown-content + #js-groups-dropdown{ data: { user_name: current_user.username, group: group_meta } } diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml index 5809d6f7fea..f2170f71532 100644 --- a/app/views/layouts/nav/projects_dropdown/_show.html.haml +++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml @@ -1,6 +1,6 @@ - project_meta = { id: @project.id, name: @project.name, namespace: @project.full_name, web_url: project_path(@project), avatar_url: @project.avatar_url } if @project&.persisted? -.projects-dropdown-container - .project-dropdown-sidebar.qa-projects-dropdown-sidebar +.frequent-items-dropdown-container + .frequent-items-dropdown-sidebar.qa-projects-dropdown-sidebar %ul = nav_link(path: 'dashboard/projects#index') do = link_to dashboard_projects_path, class: 'qa-your-projects-link' do @@ -11,5 +11,5 @@ = nav_link(path: 'projects#trending') do = link_to explore_root_path do = _('Explore projects') - .project-dropdown-content + .frequent-items-dropdown-content #js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } } diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index c14700794ce..43a2d53b84d 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -5,11 +5,18 @@ .form-group = f.label :key, class: 'label-light' %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_rsa.pub' and begins with 'ssh-rsa'. Don't use your private SSH key.") - = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: 'Typically starts with "ssh-rsa …"' + = f.text_area :key, class: "form-control js-add-ssh-key-validation-input", rows: 8, required: true, placeholder: s_('Profiles|Typically starts with "ssh-rsa …"') .form-group = f.label :title, class: 'label-light' - = f.text_field :title, class: "form-control", required: true, placeholder: 'e.g. My MacBook key' + = f.text_field :title, class: "form-control input-lg", required: true, placeholder: s_('Profiles|e.g. My MacBook key') %p.form-text.text-muted= _('Name your individual key via a title') + .js-add-ssh-key-validation-warning.hide + .bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' } + %strong= _('Oops, are you sure?') + %p= s_("Profiles|This doesn't look like a public SSH key, are you sure you want to add it?") + + %button.btn.btn-create.js-add-ssh-key-validation-confirm-submit= _("Yes, add it") + .prepend-top-default - = f.submit 'Add key', class: "btn btn-create" + = f.submit s_('Profiles|Add key'), class: "btn btn-create js-add-ssh-key-validation-original-submit" diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index f4d4888bd15..aa980da7e95 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -31,7 +31,7 @@ %li Any encrypted tokens %p Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page. - - if project.export_project_path + - if project.export_status == :finished = link_to 'Download export', download_export_project_path(project), rel: 'nofollow', download: '', method: :get, class: "btn btn-default" = link_to 'Generate new export', generate_new_export_project_path(project), diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml index 2c5ffd85372..1e4e9450ffa 100644 --- a/app/views/projects/issues/_form.html.haml +++ b/app/views/projects/issues/_form.html.haml @@ -1,2 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @issue], html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' } do |f| += form_for [@project.namespace.becomes(Namespace), @project, @issue], + html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' }, + data: { markdown_version: @issue.cached_markdown_version } do |f| = render 'shared/issuable/form', f: f, issuable: @issue diff --git a/app/views/projects/merge_requests/_form.html.haml b/app/views/projects/merge_requests/_form.html.haml index 179c1fcc684..5a59f956cb5 100644 --- a/app/views/projects/merge_requests/_form.html.haml +++ b/app/views/projects/merge_requests/_form.html.haml @@ -1,2 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| += form_for [@project.namespace.becomes(Namespace), @project, @merge_request], + html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' }, + data: { markdown_version: @merge_request.cached_markdown_version } do |f| = render 'shared/issuable/form', f: f, issuable: @merge_request diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 4cc59718715..ace094a671a 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -1,4 +1,6 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @milestone], html: {class: 'milestone-form common-note-form js-quick-submit js-requires-input'} do |f| += form_for [@project.namespace.becomes(Namespace), @project, @milestone], + html: {class: 'milestone-form common-note-form js-quick-submit js-requires-input'}, + data: { markdown_version: @milestone.cached_markdown_version } do |f| = form_errors(@milestone) .row .col-md-6 diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml index d6f758608a0..8093cc2c2d7 100644 --- a/app/views/projects/releases/edit.html.haml +++ b/app/views/projects/releases/edit.html.haml @@ -11,7 +11,9 @@ %strong= @tag.name - = form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name), html: { class: 'common-note-form release-form js-quick-submit' }) do |f| + = form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name), + html: { class: 'common-note-form release-form js-quick-submit' }, + data: { markdown_version: @release.cached_markdown_version }) do |f| = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…" = render 'shared/notes/hints' diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 26fe1de31fe..de692466fe5 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -1,7 +1,9 @@ - commit_message = @page.persisted? ? s_("WikiPageEdit|Update %{page_title}") : s_("WikiPageCreate|Create %{page_title}") - commit_message = commit_message % { page_title: @page.title } -= form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post, html: { class: 'wiki-form common-note-form prepend-top-default js-quick-submit' } do |f| += form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post, + html: { class: 'wiki-form common-note-form prepend-top-default js-quick-submit' }, + data: { markdown_version: CacheMarkdownField::CACHE_REDCARPET_VERSION } do |f| = form_errors(@page) - if @page.persisted? diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index d4e8f30e458..f5464058bc0 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -52,7 +52,7 @@ .note-text.md = markdown_field(note, :note) = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago') - .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } + .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore, markdown_version: note.cached_markdown_version } } #{note.note} - if note_editable = render 'shared/notes/edit', note: note diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 858adc8be37..5e5c050d5c3 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -2,7 +2,9 @@ = page_specific_javascript_tag('lib/ace.js') .snippet-form-holder - = form_for @snippet, url: url, html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" } do |f| + = form_for @snippet, url: url, + html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" }, + data: { markdown_version: @snippet.cached_markdown_version } do |f| = form_errors(@snippet) .form-group.row diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index b8b854853b7..d4be1ccfcfa 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -46,7 +46,6 @@ - mail_scheduler:mail_scheduler_issue_due - mail_scheduler:mail_scheduler_notification_service -- object_storage_upload - object_storage:object_storage_background_move - object_storage:object_storage_migrate_uploads diff --git a/app/workers/ci/build_trace_chunk_flush_worker.rb b/app/workers/ci/build_trace_chunk_flush_worker.rb index 6376c6d32cf..9dbf2e5e1ac 100644 --- a/app/workers/ci/build_trace_chunk_flush_worker.rb +++ b/app/workers/ci/build_trace_chunk_flush_worker.rb @@ -7,7 +7,7 @@ module Ci def perform(build_trace_chunk_id) ::Ci::BuildTraceChunk.find_by(id: build_trace_chunk_id).try do |build_trace_chunk| - build_trace_chunk.use_database! + build_trace_chunk.persist_data! end end end diff --git a/app/workers/object_storage_upload_worker.rb b/app/workers/object_storage_upload_worker.rb deleted file mode 100644 index f17980a83d8..00000000000 --- a/app/workers/object_storage_upload_worker.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -# @Deprecated - remove once the `object_storage_upload` queue is empty -# The queue has been renamed `object_storage:object_storage_background_upload` -# -class ObjectStorageUploadWorker - include ApplicationWorker - - sidekiq_options retry: 5 - - def perform(uploader_class_name, subject_class_name, file_field, subject_id) - uploader_class = uploader_class_name.constantize - subject_class = subject_class_name.constantize - - return unless uploader_class < ObjectStorage::Concern - return unless uploader_class.object_store_enabled? - return unless uploader_class.background_upload_enabled? - - subject = subject_class.find(subject_id) - uploader = subject.public_send(file_field) # rubocop:disable GitlabSecurity/PublicSend - uploader.migrate!(ObjectStorage::Store::REMOTE) - end -end diff --git a/app/workers/repository_check/batch_worker.rb b/app/workers/repository_check/batch_worker.rb index 051382a08a9..07559ea479b 100644 --- a/app/workers/repository_check/batch_worker.rb +++ b/app/workers/repository_check/batch_worker.rb @@ -4,9 +4,11 @@ module RepositoryCheck class BatchWorker include ApplicationWorker include RepositoryCheckQueue + include ExclusiveLeaseGuard RUN_TIME = 3600 BATCH_SIZE = 10_000 + LEASE_TIMEOUT = 1.hour attr_reader :shard_name @@ -16,6 +18,20 @@ module RepositoryCheck return unless Gitlab::CurrentSettings.repository_checks_enabled return unless Gitlab::ShardHealthCache.healthy_shard?(shard_name) + try_obtain_lease do + perform_repository_checks + end + end + + def lease_timeout + LEASE_TIMEOUT + end + + def lease_key + "repository_check_batch_worker:#{shard_name}" + end + + def perform_repository_checks start = Time.now # This loop will break after a little more than one hour ('a little @@ -26,7 +42,7 @@ module RepositoryCheck project_ids.each do |project_id| break if Time.now - start >= RUN_TIME - next unless try_obtain_lease(project_id) + next unless try_obtain_lease_for_project(project_id) SingleRepositoryWorker.new.perform(project_id) end @@ -60,7 +76,7 @@ module RepositoryCheck Project.where(repository_storage: shard_name) end - def try_obtain_lease(id) + def try_obtain_lease_for_project(id) # Use a 24-hour timeout because on servers/projects where 'git fsck' is # super slow we definitely do not want to run it twice in parallel. Gitlab::ExclusiveLease.new( diff --git a/app/workers/repository_check/dispatch_worker.rb b/app/workers/repository_check/dispatch_worker.rb index 891a273afd7..96634f09a15 100644 --- a/app/workers/repository_check/dispatch_worker.rb +++ b/app/workers/repository_check/dispatch_worker.rb @@ -3,13 +3,22 @@ module RepositoryCheck include ApplicationWorker include CronjobQueue include ::EachShardWorker + include ExclusiveLeaseGuard + + LEASE_TIMEOUT = 1.hour def perform return unless Gitlab::CurrentSettings.repository_checks_enabled - each_eligible_shard do |shard_name| - RepositoryCheck::BatchWorker.perform_async(shard_name) + try_obtain_lease do + each_eligible_shard do |shard_name| + RepositoryCheck::BatchWorker.perform_async(shard_name) + end end end + + def lease_timeout + LEASE_TIMEOUT + end end end diff --git a/changelogs/unreleased/36234-nav-add-groups-dropdown.yml b/changelogs/unreleased/36234-nav-add-groups-dropdown.yml new file mode 100644 index 00000000000..86a24102665 --- /dev/null +++ b/changelogs/unreleased/36234-nav-add-groups-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Add dropdown to Groups link in top bar +merge_request: 18280 +author: +type: added diff --git a/changelogs/unreleased/46246-gitlab-project-export-should-use-object-storage.yml b/changelogs/unreleased/46246-gitlab-project-export-should-use-object-storage.yml new file mode 100644 index 00000000000..908c7a238fd --- /dev/null +++ b/changelogs/unreleased/46246-gitlab-project-export-should-use-object-storage.yml @@ -0,0 +1,5 @@ +--- +title: Add Object Storage to project export +merge_request: 20105 +author: +type: added diff --git a/changelogs/unreleased/46396-recognise-when-a-user-is-trying-to-validate-a-private-ssh-key.yml b/changelogs/unreleased/46396-recognise-when-a-user-is-trying-to-validate-a-private-ssh-key.yml new file mode 100644 index 00000000000..64bbecf3405 --- /dev/null +++ b/changelogs/unreleased/46396-recognise-when-a-user-is-trying-to-validate-a-private-ssh-key.yml @@ -0,0 +1,5 @@ +--- +title: Update new SSH key page to improve key input validation +merge_request: 19997 +author: +type: other diff --git a/changelogs/unreleased/48661-node-6-and-7-compatibility-broken-by-recent-monaco-editor-upgrade.yml b/changelogs/unreleased/48661-node-6-and-7-compatibility-broken-by-recent-monaco-editor-upgrade.yml new file mode 100644 index 00000000000..36a4b5f754d --- /dev/null +++ b/changelogs/unreleased/48661-node-6-and-7-compatibility-broken-by-recent-monaco-editor-upgrade.yml @@ -0,0 +1,5 @@ +--- +title: Resolve compatibility issues with node 6 +merge_request: 20461 +author: +type: fixed diff --git a/changelogs/unreleased/48670-application-settings-may-not-be-invalidated-if-migrations-are-run.yml b/changelogs/unreleased/48670-application-settings-may-not-be-invalidated-if-migrations-are-run.yml new file mode 100644 index 00000000000..f4267582f89 --- /dev/null +++ b/changelogs/unreleased/48670-application-settings-may-not-be-invalidated-if-migrations-are-run.yml @@ -0,0 +1,6 @@ +--- +title: Stop relying on migrations in the CacheableAttributes cache key and cache attributes + for 1 minute instead +merge_request: 20389 +author: +type: fixed diff --git a/changelogs/unreleased/48677-also-check-auto_sign_in_with_provider.yml b/changelogs/unreleased/48677-also-check-auto_sign_in_with_provider.yml new file mode 100644 index 00000000000..3021fe6b9c8 --- /dev/null +++ b/changelogs/unreleased/48677-also-check-auto_sign_in_with_provider.yml @@ -0,0 +1,5 @@ +--- +title: Load Devise with Omniauth when auto_sign_in_with_provider is configured +merge_request: 20302 +author: +type: fixed diff --git a/changelogs/unreleased/blackst0ne-rails5-activerecord-statementinvalid-mysql2-error-expression-1-of-select-list-is-not-in-group-by-clause.yml b/changelogs/unreleased/blackst0ne-rails5-activerecord-statementinvalid-mysql2-error-expression-1-of-select-list-is-not-in-group-by-clause.yml new file mode 100644 index 00000000000..d9cccc49830 --- /dev/null +++ b/changelogs/unreleased/blackst0ne-rails5-activerecord-statementinvalid-mysql2-error-expression-1-of-select-list-is-not-in-group-by-clause.yml @@ -0,0 +1,5 @@ +--- +title: "[Rails5] Fix milestone GROUP BY query" +merge_request: 20256 +author: "@blackst0ne" +type: fixed diff --git a/changelogs/unreleased/build-chunks-on-object-storage.yml b/changelogs/unreleased/build-chunks-on-object-storage.yml new file mode 100644 index 00000000000..9f36dfee378 --- /dev/null +++ b/changelogs/unreleased/build-chunks-on-object-storage.yml @@ -0,0 +1,6 @@ +--- +title: Use object storage as the first class persistable store for new live trace + architecture +merge_request: 19515 +author: +type: changed diff --git a/changelogs/unreleased/bvl-preload-parents-after-pagination.yml b/changelogs/unreleased/bvl-preload-parents-after-pagination.yml new file mode 100644 index 00000000000..ff3d4716d34 --- /dev/null +++ b/changelogs/unreleased/bvl-preload-parents-after-pagination.yml @@ -0,0 +1,5 @@ +--- +title: Reduce the number of queries when searching for groups +merge_request: 20398 +author: +type: performance diff --git a/changelogs/unreleased/fl-mr-refactor-performance-improvements.yml b/changelogs/unreleased/fl-mr-refactor-performance-improvements.yml new file mode 100644 index 00000000000..649d1b5da67 --- /dev/null +++ b/changelogs/unreleased/fl-mr-refactor-performance-improvements.yml @@ -0,0 +1,5 @@ +--- +title: Structure getters for diff Store properly and adds specs +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/ide-merge-request-info.yml b/changelogs/unreleased/ide-merge-request-info.yml new file mode 100644 index 00000000000..104f48ae309 --- /dev/null +++ b/changelogs/unreleased/ide-merge-request-info.yml @@ -0,0 +1,5 @@ +--- +title: Display merge request title & description in Web IDE +merge_request: +author: +type: added diff --git a/changelogs/unreleased/jprovazn-delete-upload-worker.yml b/changelogs/unreleased/jprovazn-delete-upload-worker.yml new file mode 100644 index 00000000000..52916482d32 --- /dev/null +++ b/changelogs/unreleased/jprovazn-delete-upload-worker.yml @@ -0,0 +1,5 @@ +--- +title: Remove deprecated object_storage_upload queue. +merge_request: +author: +type: removed diff --git a/changelogs/unreleased/jprovazn-label-links-update.yml b/changelogs/unreleased/jprovazn-label-links-update.yml new file mode 100644 index 00000000000..75fb46ede6b --- /dev/null +++ b/changelogs/unreleased/jprovazn-label-links-update.yml @@ -0,0 +1,5 @@ +--- +title: Fix cross-project label references. +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/perf-wiki-pattern-once.yml b/changelogs/unreleased/perf-wiki-pattern-once.yml new file mode 100644 index 00000000000..fb4085a06ae --- /dev/null +++ b/changelogs/unreleased/perf-wiki-pattern-once.yml @@ -0,0 +1,5 @@ +--- +title: Improve render performance of large wiki pages +merge_request: 20465 +author: Peter Leitzen +type: performance diff --git a/changelogs/unreleased/rosulk-patch-12.yml b/changelogs/unreleased/rosulk-patch-12.yml deleted file mode 100644 index 9637c88d1a4..00000000000 --- a/changelogs/unreleased/rosulk-patch-12.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Flex issue board columns -merge_request: 19250 -author: Roman Rosluk -type: changed diff --git a/config/environments/development.rb b/config/environments/development.rb index 45a8c1add3e..23790b84e3c 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -39,7 +39,7 @@ Rails.application.configure do config.action_mailer.delivery_method = :letter_opener_web # Don't make a mess when bootstrapping a development environment config.action_mailer.perform_deliveries = (ENV['BOOTSTRAP'] != '1') - config.action_mailer.preview_path = 'spec/mailers/previews' + config.action_mailer.preview_path = 'app/mailers/previews' config.eager_load = false diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index d051b699102..e5772c33307 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -219,7 +219,7 @@ Devise.setup do |config| end end - if Gitlab.config.omniauth.enabled + if Gitlab::OmniauthInitializer.enabled? Gitlab::OmniauthInitializer.new(config).execute(Gitlab.config.omniauth.providers) end end diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index a7fa926a853..c558eb28ced 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -17,7 +17,7 @@ OmniAuth.config.before_request_phase do |env| Gitlab::RequestForgeryProtection.call(env) end -if Gitlab.config.omniauth.enabled +if Gitlab::OmniauthInitializer.enabled? provider_names = Gitlab.config.omniauth.providers.map(&:name) Gitlab::Auth.omniauth_setup_providers(provider_names) end diff --git a/db/migrate/20180625113853_create_import_export_uploads.rb b/db/migrate/20180625113853_create_import_export_uploads.rb new file mode 100644 index 00000000000..be42304b0ae --- /dev/null +++ b/db/migrate/20180625113853_create_import_export_uploads.rb @@ -0,0 +1,16 @@ +class CreateImportExportUploads < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :import_export_uploads do |t| + t.datetime_with_timezone :updated_at, null: false + + t.references :project, index: true, foreign_key: { on_delete: :cascade }, unique: true + + t.text :import_file + t.text :export_file + end + + add_index :import_export_uploads, :updated_at + end +end diff --git a/db/post_migrate/20180702120647_enqueue_fix_cross_project_label_links.rb b/db/post_migrate/20180702120647_enqueue_fix_cross_project_label_links.rb new file mode 100644 index 00000000000..59aa41adede --- /dev/null +++ b/db/post_migrate/20180702120647_enqueue_fix_cross_project_label_links.rb @@ -0,0 +1,30 @@ +class EnqueueFixCrossProjectLabelLinks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + BATCH_SIZE = 100 + MIGRATION = 'FixCrossProjectLabelLinks' + DELAY_INTERVAL = 5.minutes + + disable_ddl_transaction! + + class Label < ActiveRecord::Base + self.table_name = 'labels' + end + + class Namespace < ActiveRecord::Base + self.table_name = 'namespaces' + + include ::EachBatch + + default_scope { where(type: 'Group', id: Label.where(type: 'GroupLabel').select('distinct group_id')) } + end + + def up + queue_background_migration_jobs_by_range_at_intervals(Namespace, MIGRATION, DELAY_INTERVAL, batch_size: BATCH_SIZE) + end + + def down + # noop + end +end diff --git a/db/schema.rb b/db/schema.rb index c9aaf80f059..1898dfc6022 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180629191052) do +ActiveRecord::Schema.define(version: 20180702120647) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -949,6 +949,16 @@ ActiveRecord::Schema.define(version: 20180629191052) do add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree + create_table "import_export_uploads", force: :cascade do |t| + t.datetime_with_timezone "updated_at", null: false + t.integer "project_id" + t.text "import_file" + t.text "export_file" + end + + add_index "import_export_uploads", ["project_id"], name: "index_import_export_uploads_on_project_id", using: :btree + add_index "import_export_uploads", ["updated_at"], name: "index_import_export_uploads_on_updated_at", using: :btree + create_table "internal_ids", id: :bigserial, force: :cascade do |t| t.integer "project_id" t.integer "usage", null: false @@ -2252,6 +2262,7 @@ ActiveRecord::Schema.define(version: 20180629191052) do add_foreign_key "gpg_signatures", "gpg_keys", on_delete: :nullify add_foreign_key "gpg_signatures", "projects", on_delete: :cascade add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade + add_foreign_key "import_export_uploads", "projects", on_delete: :cascade add_foreign_key "internal_ids", "namespaces", name: "fk_162941d509", on_delete: :cascade add_foreign_key "internal_ids", "projects", on_delete: :cascade add_foreign_key "issue_assignees", "issues", name: "fk_b7d881734a", on_delete: :cascade diff --git a/doc/administration/job_traces.md b/doc/administration/job_traces.md index f1c5b194f4c..24d1a3fd151 100644 --- a/doc/administration/job_traces.md +++ b/doc/administration/job_traces.md @@ -77,10 +77,10 @@ cloud-native, for example on Kubernetes. The data flow is the same as described in the [data flow section](#data-flow) with one change: _the stored path of the first two phases is different_. This new live -trace architecture stores chunks of traces in Redis and the database instead of +trace architecture stores chunks of traces in Redis and a persistent store (object storage or database) instead of file storage. Redis is used as first-class storage, and it stores up-to 128KB -of data. Once the full chunk is sent, it is flushed to database. After a while, -the data in Redis and database will be archived to [object storage](#uploading-traces-to-object-storage). +of data. Once the full chunk is sent, it is flushed a persistent store, either object storage(temporary directory) or database. +After a while, the data in Redis and a persitent store will be archived to [object storage](#uploading-traces-to-object-storage). The data are stored in the following Redis namespace: `Gitlab::Redis::SharedState`. @@ -89,11 +89,11 @@ Here is the detailed data flow: 1. GitLab Runner picks a job from GitLab 1. GitLab Runner sends a piece of trace to GitLab 1. GitLab appends the data to Redis -1. Once the data in Redis reach 128KB, the data is flushed to the database. +1. Once the data in Redis reach 128KB, the data is flushed to a persistent store (object storage or the database). 1. The above steps are repeated until the job is finished. 1. Once the job is finished, GitLab schedules a Sidekiq worker to archive the trace. 1. The Sidekiq worker archives the trace to object storage and cleans up the trace - in Redis and the database. + in Redis and a persistent store (object storage or the database). ### Enabling live trace diff --git a/doc/administration/raketasks/project_import_export.md b/doc/administration/raketasks/project_import_export.md index ecc4ac6b29b..7bd765a35e0 100644 --- a/doc/administration/raketasks/project_import_export.md +++ b/doc/administration/raketasks/project_import_export.md @@ -30,5 +30,12 @@ sudo gitlab-rake gitlab:import_export:data bundle exec rake gitlab:import_export:data RAILS_ENV=production ``` +In order to enable Object Storage on the Export, you can use the [feature flag][feature-flags]: + +``` +import_export_object_storage +``` + [ce-3050]: https://gitlab.com/gitlab-org/gitlab-ce/issues/3050 +[feature-flags]: https://docs.gitlab.com/ee/api/features.html [tmp]: ../../development/shared_files.md diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 2057ed3588a..34c2dd7b34d 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -358,6 +358,7 @@ Parameters: - `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user - `merge_request_iid` (required) - The internal ID of the merge request +- `render_html` (optional) - If `true` response includes rendered HTML for title and description ```json { diff --git a/doc/development/emails.md b/doc/development/emails.md index 73cac82caf0..35ada35babe 100644 --- a/doc/development/emails.md +++ b/doc/development/emails.md @@ -10,12 +10,12 @@ To view rendered emails "sent" in your development instance, visit Rails provides a way to preview our mailer templates in HTML and plaintext using dummy data. -The previews live in [`spec/mailers/previews`][previews] and can be viewed at +The previews live in [`app/mailers/previews`][previews] and can be viewed at [`/rails/mailers`](http://localhost:3000/rails/mailers). See the [Rails guides] for more info. -[previews]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/spec/mailers/previews +[previews]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/app/mailers/previews [Rails guides]: http://guides.rubyonrails.org/action_mailer_basics.html#previewing-emails ## Incoming email diff --git a/doc/development/licensing.md b/doc/development/licensing.md index c06bc0d4731..ddaf636a742 100644 --- a/doc/development/licensing.md +++ b/doc/development/licensing.md @@ -60,7 +60,7 @@ Libraries with the following licenses are acceptable for use: ## Unacceptable Licenses -Libraries with the following licenses are unacceptable for use: +Libraries with the following licenses require legal approval for use: - [GNU GPL][GPL] (version 1, [version 2][GPLv2], [version 3][GPLv3], or any future versions): GPL-licensed libraries cannot be linked to from non-GPL projects. - [GNU AGPLv3][AGPLv3]: AGPL-licensed libraries cannot be linked to from non-GPL projects. @@ -68,6 +68,26 @@ Libraries with the following licenses are unacceptable for use: - [Facebook BSD + PATENTS][Facebook]: is a 3-clause BSD license with a patent grant that has been deemed [Category X][x-list] by the Apache foundation. - [WTFPL][WTFPL]: is a public domain dedication [rejected by the OSI (3.2)][WTFPL-OSI]. Also has a strong language which is not in accordance with our diversity policy. +## GPL Cooperation Commitment + +Before filing or continuing to prosecute any legal proceeding or claim (other than a Defensive Action) arising from termination of a Covered License, GitLab commits to extend to the person or entity (“you”) accused of violating the Covered License the following provisions regarding cure and reinstatement, taken from GPL version 3. As used here, the term ‘this License’ refers to the specific Covered License being enforced. + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +GitLab intends this Commitment to be irrevocable, and binding and enforceable against GitLab and assignees of or successors to GitLab’s copyrights. + +GitLab may modify this Commitment by publishing a new edition on this page or a successor location. + +Definitions + +‘Covered License’ means the GNU General Public License, version 2 (GPLv2), the GNU Lesser General Public License, version 2.1 (LGPLv2.1), or the GNU Library General Public License, version 2 (LGPLv2), all as published by the Free Software Foundation. + +‘Defensive Action’ means a legal proceeding or claim that GitLab brings against you in response to a prior proceeding or claim initiated by you or your affiliate. + +GitLab means GitLab Inc. and its affiliates and subsidiaries. + ## Requesting Approval for Licenses Libraries that are not listed in the [Acceptable Licenses][Acceptable-Licenses] or [Unacceptable Licenses][Unacceptable-Licenses] list can be submitted to the legal team for review. Please email `legal@gitlab.com` with the details. After a decision has been made, the original requestor is responsible for updating this document. diff --git a/doc/integration/bitbucket.md b/doc/integration/bitbucket.md index 2a14c0397ca..9094d1f2419 100644 --- a/doc/integration/bitbucket.md +++ b/doc/integration/bitbucket.md @@ -1,5 +1,8 @@ # Integrate your GitLab server with Bitbucket +NOTE: **Note:** +You need to [enable OmniAuth](omniauth.md) in order to use this. + Import projects from Bitbucket.org and login to your GitLab instance with your Bitbucket.org account. @@ -76,13 +79,13 @@ you to use. sudo -u git -H editor /home/git/gitlab/config/gitlab.yml ``` -1. Follow the [Initial OmniAuth Configuration](omniauth.md#initial-omniauth-configuration) - for initial settings. 1. Add the Bitbucket provider configuration: For Omnibus packages: ```ruby + gitlab_rails['omniauth_enabled'] = true + gitlab_rails['omniauth_providers'] = [ { "name" => "bitbucket", @@ -96,10 +99,13 @@ you to use. For installations from source: ```yaml - - { name: 'bitbucket', - app_id: 'BITBUCKET_APP_KEY', - app_secret: 'BITBUCKET_APP_SECRET', - url: 'https://bitbucket.org/' } + omniauth: + enabled: true + providers: + - { name: 'bitbucket', + app_id: 'BITBUCKET_APP_KEY', + app_secret: 'BITBUCKET_APP_SECRET', + url: 'https://bitbucket.org/' } ``` --- @@ -121,6 +127,9 @@ well, the user will be returned to GitLab and will be signed in. Once the above configuration is set up, you can use Bitbucket to sign into GitLab and [start importing your projects][bb-import]. +If you don't want to enable signing in with Bitbucket but just want to import +projects from Bitbucket, you could [disable it in the admin panel](omniauth.md#enable-or-disable-sign-in-with-an-omniauth-provider-without-disabling-import-sources). + [init-oauth]: omniauth.md#initial-omniauth-configuration [bb-import]: ../workflow/importing/import_projects_from_bitbucket.md [bb-old]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-14-stable/doc/integration/bitbucket.md diff --git a/doc/integration/saml.md b/doc/integration/saml.md index db06efdae53..25f396bc9c4 100644 --- a/doc/integration/saml.md +++ b/doc/integration/saml.md @@ -1,5 +1,8 @@ # SAML OmniAuth Provider +NOTE: **Note:** +You need to [enable OmniAuth](omniauth.md) in order to use this. + GitLab can be configured to act as a SAML 2.0 Service Provider (SP). This allows GitLab to consume assertions from a SAML 2.0 Identity Provider (IdP) such as Microsoft ADFS to authenticate users. @@ -15,33 +18,33 @@ in your SAML IdP: For omnibus package: ```sh - sudo editor /etc/gitlab/gitlab.rb + sudo editor /etc/gitlab/gitlab.rb ``` For installations from source: ```sh - cd /home/git/gitlab + cd /home/git/gitlab - sudo -u git -H editor config/gitlab.yml + sudo -u git -H editor config/gitlab.yml ``` -1. See [Initial OmniAuth Configuration](omniauth.md#initial-omniauth-configuration) - for initial settings. - 1. To allow your users to use SAML to sign up without having to manually create an account first, don't forget to add the following values to your configuration: For omnibus package: ```ruby - gitlab_rails['omniauth_allow_single_sign_on'] = ['saml'] - gitlab_rails['omniauth_block_auto_created_users'] = false + gitlab_rails['omniauth_enabled'] = true + gitlab_rails['omniauth_allow_single_sign_on'] = ['saml'] + gitlab_rails['omniauth_block_auto_created_users'] = false ``` For installations from source: ```yaml + omniauth: + enabled: true allow_single_sign_on: ["saml"] block_auto_created_users: false ``` @@ -52,13 +55,13 @@ in your SAML IdP: For omnibus package: ```ruby - gitlab_rails['omniauth_auto_link_saml_user'] = true + gitlab_rails['omniauth_auto_link_saml_user'] = true ``` For installations from source: ```yaml - auto_link_saml_user: true + auto_link_saml_user: true ``` 1. Add the provider configuration: @@ -66,35 +69,37 @@ in your SAML IdP: For omnibus package: ```ruby - gitlab_rails['omniauth_providers'] = [ - { - name: 'saml', - args: { - assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback', - idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8', - idp_sso_target_url: 'https://login.example.com/idp', - issuer: 'https://gitlab.example.com', - name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' - }, - label: 'Company Login' # optional label for SAML login button, defaults to "Saml" - } - ] - ``` - - For installations from source: - - ```yaml - - { - name: 'saml', - args: { + gitlab_rails['omniauth_providers'] = [ + { + name: 'saml', + args: { assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback', idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8', idp_sso_target_url: 'https://login.example.com/idp', issuer: 'https://gitlab.example.com', name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' }, - label: 'Company Login' # optional label for SAML login button, defaults to "Saml" - } + label: 'Company Login' # optional label for SAML login button, defaults to "Saml" + } + ] + ``` + + For installations from source: + + ```yaml + omniauth: + providers: + - { + name: 'saml', + args: { + assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback', + idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8', + idp_sso_target_url: 'https://login.example.com/idp', + issuer: 'https://gitlab.example.com', + name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' + }, + label: 'Company Login' # optional label for SAML login button, defaults to "Saml" + } ``` 1. Change the value for `assertion_consumer_service_url` to match the HTTPS endpoint @@ -140,8 +145,8 @@ This setting is only available on GitLab 8.7 and above. SAML login includes support for automatically identifying whether a user should be considered an [external](../user/permissions.md) user based on the user's group membership in the SAML identity provider. This feature **does not** allow you to -automatically add users to GitLab [Groups](../user/group/index.md), it simply -allows you to mark users as External if they are members of certain groups in the +automatically add users to GitLab [Groups](../user/group/index.md), it simply +allows you to mark users as External if they are members of certain groups in the Identity Provider. ### Requirements @@ -189,28 +194,28 @@ If you want some SAML authentication methods to count as 2FA on a per session ba 1. Edit `/etc/gitlab/gitlab.rb`: ```ruby - gitlab_rails['omniauth_providers'] = [ - { - name: 'saml', - args: { - assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback', - idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8', - idp_sso_target_url: 'https://login.example.com/idp', - issuer: 'https://gitlab.example.com', - name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', - upstream_two_factor_authn_contexts: - %w( - urn:oasis:names:tc:SAML:2.0:ac:classes:CertificateProtectedTransport - urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS - urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorIGTOKEN - ) - - }, - label: 'Company Login' # optional label for SAML login button, defaults to "Saml" - } - ] + gitlab_rails['omniauth_providers'] = [ + { + name: 'saml', + args: { + assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback', + idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8', + idp_sso_target_url: 'https://login.example.com/idp', + issuer: 'https://gitlab.example.com', + name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + upstream_two_factor_authn_contexts: + %w( + urn:oasis:names:tc:SAML:2.0:ac:classes:CertificateProtectedTransport + urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS + urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorIGTOKEN + ) + + }, + label: 'Company Login' # optional label for SAML login button, defaults to "Saml" + } + ] ``` - + 1. Save the file and [reconfigure][] GitLab for the changes to take effect. --- @@ -218,40 +223,41 @@ If you want some SAML authentication methods to count as 2FA on a per session ba **For installations from source:** 1. Edit `config/gitlab.yml`: - - ```yaml - - { - name: 'saml', - args: { - assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback', - idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8', - idp_sso_target_url: 'https://login.example.com/idp', - issuer: 'https://gitlab.example.com', - name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', - upstream_two_factor_authn_contexts: - [ - 'urn:oasis:names:tc:SAML:2.0:ac:classes:CertificateProtectedTransport', - 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS', - 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorIGTOKEN' - ] - - }, - label: 'Company Login' # optional label for SAML login button, defaults to "Saml" - } + + ```yaml + omniauth: + providers: + - { + name: 'saml', + args: { + assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback', + idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8', + idp_sso_target_url: 'https://login.example.com/idp', + issuer: 'https://gitlab.example.com', + name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + upstream_two_factor_authn_contexts: + [ + 'urn:oasis:names:tc:SAML:2.0:ac:classes:CertificateProtectedTransport', + 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS', + 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorIGTOKEN' + ] + }, + label: 'Company Login' # optional label for SAML login button, defaults to "Saml" + } ``` - + 1. Save the file and [restart GitLab][] for the changes ot take effect - + In addition to the changes in GitLab, make sure that your Idp is returning the `AuthnContext`. For example: ```xml - <saml:AuthnStatement> - <saml:AuthnContext> - <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:MediumStrongCertificateProtectedTransport</saml:AuthnContextClassRef> - </saml:AuthnContext> - </saml:AuthnStatement> +<saml:AuthnStatement> + <saml:AuthnContext> + <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:MediumStrongCertificateProtectedTransport</saml:AuthnContextClassRef> + </saml:AuthnContext> +</saml:AuthnStatement> ``` ## Customization diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index b25b09f7b1f..377e285a731 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -206,7 +206,7 @@ kubectl get svc --all-namespaces -o jsonpath='{range.items[?(@.status.loadBalanc > **Note**: Some Kubernetes clusters return a hostname instead, like [Amazon EKS](https://aws.amazon.com/eks/). For these platforms, run: > ```bash -> kubectl get service ingress-nginx-ingress-controller -n gitlab-managed-apps -o jsonpath="{.status.loadBalancer.ingress[0].hostname}"`. +> kubectl get service ingress-nginx-ingress-controller -n gitlab-managed-apps -o jsonpath="{.status.loadBalancer.ingress[0].hostname}". > ``` The output is the external IP address of your cluster. This information can then diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 3a6e707fd5b..40df1e79bc7 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -532,6 +532,12 @@ module API end class MergeRequestBasic < ProjectEntity + expose :title_html, if: -> (_, options) { options[:render_html] } do |entity| + MarkupHelper.markdown_field(entity, :title) + end + expose :description_html, if: -> (_, options) { options[:render_html] } do |entity| + MarkupHelper.markdown_field(entity, :description) + end expose :target_branch, :source_branch expose :upvotes do |merge_request, options| if options[:issuable_metadata] diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 0f46bc4c98e..2621c9f8fc2 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -232,6 +232,7 @@ module API params do requires :merge_request_iid, type: Integer, desc: 'The IID of a merge request' + optional :render_html, type: Boolean, desc: 'Returns the description and title rendered HTML' end desc 'Get a single merge request' do success Entities::MergeRequest @@ -239,7 +240,7 @@ module API get ':id/merge_requests/:merge_request_iid' do merge_request = find_merge_request_with_access(params[:merge_request_iid]) - present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project + present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project, render_html: params[:render_html] end desc 'Get the participants of a merge request' do diff --git a/lib/api/project_export.rb b/lib/api/project_export.rb index 5ef4e9d530c..15c57a2fc02 100644 --- a/lib/api/project_export.rb +++ b/lib/api/project_export.rb @@ -23,9 +23,13 @@ module API get ':id/export/download' do path = user_project.export_project_path - render_api_error!('404 Not found or has expired', 404) unless path - - present_disk_file!(path, File.basename(path), 'application/gzip') + if path + present_disk_file!(path, File.basename(path), 'application/gzip') + elsif user_project.export_project_object_exists? + present_carrierwave_file!(user_project.import_export_upload.export_file) + else + render_api_error!('404 Not found or has expired', 404) + end end desc 'Start export' do diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb index 60a12dca9d3..b39b11009b3 100644 --- a/lib/banzai/filter/abstract_reference_filter.rb +++ b/lib/banzai/filter/abstract_reference_filter.rb @@ -100,6 +100,11 @@ module Banzai ref_pattern = object_class.reference_pattern link_pattern = object_class.link_reference_pattern + # Compile often used regexps only once outside of the loop + ref_pattern_anchor = /\A#{ref_pattern}\z/ + link_pattern_start = /\A#{link_pattern}/ + link_pattern_anchor = /\A#{link_pattern}\z/ + nodes.each do |node| if text_node?(node) && ref_pattern replace_text_when_pattern_matches(node, ref_pattern) do |content| @@ -108,7 +113,7 @@ module Banzai elsif element_node?(node) yield_valid_link(node) do |link, inner_html| - if ref_pattern && link =~ /\A#{ref_pattern}\z/ + if ref_pattern && link =~ ref_pattern_anchor replace_link_node_with_href(node, link) do object_link_filter(link, ref_pattern, link_content: inner_html) end @@ -118,7 +123,7 @@ module Banzai next unless link_pattern - if link == inner_html && inner_html =~ /\A#{link_pattern}/ + if link == inner_html && inner_html =~ link_pattern_start replace_link_node_with_text(node, link) do object_link_filter(inner_html, link_pattern, link_reference: true) end @@ -126,7 +131,7 @@ module Banzai next end - if link =~ /\A#{link_pattern}\z/ + if link =~ link_pattern_anchor replace_link_node_with_href(node, link) do object_link_filter(link, link_pattern, link_content: inner_html, link_reference: true) end diff --git a/lib/gitlab.rb b/lib/gitlab.rb index b9a148f35bf..ab6b609d099 100644 --- a/lib/gitlab.rb +++ b/lib/gitlab.rb @@ -9,10 +9,6 @@ module Gitlab Settings end - def self.migrations_hash - @_migrations_hash ||= Digest::MD5.hexdigest(ActiveRecord::Migrator.get_all_versions.to_s) - end - def self.revision @_revision ||= begin if File.exist?(root.join("REVISION")) diff --git a/lib/gitlab/background_migration/fix_cross_project_label_links.rb b/lib/gitlab/background_migration/fix_cross_project_label_links.rb new file mode 100644 index 00000000000..fa68ba5cca7 --- /dev/null +++ b/lib/gitlab/background_migration/fix_cross_project_label_links.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true +# rubocop:disable Style/Documentation + +module Gitlab + module BackgroundMigration + class FixCrossProjectLabelLinks + GROUP_NESTED_LEVEL = 10.freeze + + class Project < ActiveRecord::Base + self.table_name = 'projects' + end + + class Label < ActiveRecord::Base + self.table_name = 'labels' + end + + class LabelLink < ActiveRecord::Base + self.table_name = 'label_links' + end + + class Issue < ActiveRecord::Base + self.table_name = 'issues' + end + + class MergeRequest < ActiveRecord::Base + self.table_name = 'merge_requests' + end + + class Namespace < ActiveRecord::Base + self.table_name = 'namespaces' + + def self.groups_with_descendants_ids(start_id, stop_id) + # To isolate migration code, we avoid usage of + # Gitlab::GroupHierarchy#base_and_descendants which already + # does this job better + ids = Namespace.where(type: 'Group', id: Label.where(type: 'GroupLabel').select('distinct group_id')).where(id: start_id..stop_id).pluck(:id) + group_ids = ids + + GROUP_NESTED_LEVEL.times do + ids = Namespace.where(type: 'Group', parent_id: ids).pluck(:id) + break if ids.empty? + + group_ids += ids + end + + group_ids.uniq + end + end + + def perform(start_id, stop_id) + group_ids = Namespace.groups_with_descendants_ids(start_id, stop_id) + project_ids = Project.where(namespace_id: group_ids).select(:id) + + fix_issues(project_ids) + fix_merge_requests(project_ids) + end + + private + + # select IDs of issues which reference a label which is: + # a) a project label of a different project, or + # b) a group label of a different group than issue's project group + def fix_issues(project_ids) + issue_ids = Label + .joins('INNER JOIN label_links ON label_links.label_id = labels.id AND label_links.target_type = \'Issue\' + INNER JOIN issues ON issues.id = label_links.target_id + INNER JOIN projects ON projects.id = issues.project_id') + .where('issues.project_id in (?)', project_ids) + .where('(labels.project_id is not null and labels.project_id != issues.project_id) '\ + 'or (labels.group_id is not null and labels.group_id != projects.namespace_id)') + .select('distinct issues.id') + + Issue.where(id: issue_ids).find_each { |issue| check_resource_labels(issue, issue.project_id) } + end + + # select IDs of MRs which reference a label which is: + # a) a project label of a different project, or + # b) a group label of a different group than MR's project group + def fix_merge_requests(project_ids) + mr_ids = Label + .joins('INNER JOIN label_links ON label_links.label_id = labels.id AND label_links.target_type = \'MergeRequest\' + INNER JOIN merge_requests ON merge_requests.id = label_links.target_id + INNER JOIN projects ON projects.id = merge_requests.target_project_id') + .where('merge_requests.target_project_id in (?)', project_ids) + .where('(labels.project_id is not null and labels.project_id != merge_requests.target_project_id) '\ + 'or (labels.group_id is not null and labels.group_id != projects.namespace_id)') + .select('distinct merge_requests.id') + + MergeRequest.where(id: mr_ids).find_each { |merge_request| check_resource_labels(merge_request, merge_request.target_project_id) } + end + + def check_resource_labels(resource, project_id) + local_labels = available_labels(project_id) + + # get all label links for the given resource (issue/MR) + # which reference a label not included in avaiable_labels + # (other than its project labels and labels of ancestor groups) + cross_labels = LabelLink + .select('label_id, labels.title as title, labels.color as color, label_links.id as label_link_id') + .joins('INNER JOIN labels ON labels.id = label_links.label_id') + .where(target_type: resource.class.name.demodulize, target_id: resource.id) + .where('labels.id not in (?)', local_labels.select(:id)) + + cross_labels.each do |label| + matching_label = local_labels.find {|l| l.title == label.title && l.color == label.color} + + next unless matching_label + + Rails.logger.info "#{resource.class.name.demodulize} #{resource.id}: replacing #{label.label_id} with #{matching_label.id}" + LabelLink.update(label.label_link_id, label_id: matching_label.id) + end + end + + # get all labels available for the project (including + # group labels of ancestor groups) + def available_labels(project_id) + @labels ||= {} + @labels[project_id] ||= Label + .where("(type = 'GroupLabel' and group_id in (?)) or (type = 'ProjectLabel' and id = ?)", + project_group_ids(project_id), + project_id) + end + + def project_group_ids(project_id) + ids = [Project.find(project_id).namespace_id] + + GROUP_NESTED_LEVEL.times do + group = Namespace.find(ids.last) + break unless group.parent_id + + ids << group.parent_id + end + + ids + end + end + end +end diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index 4ad106e7b0a..872e70f9a5d 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -42,6 +42,21 @@ module Gitlab !self.read_only? end + # check whether the underlying database is in read-only mode + def self.db_read_only? + if postgresql? + ActiveRecord::Base.connection.execute('SELECT pg_is_in_recovery()') + .first + .fetch('pg_is_in_recovery') == 't' + else + false + end + end + + def self.db_read_write? + !self.db_read_only? + end + def self.version @version ||= database_version.match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1] end diff --git a/lib/gitlab/exclusive_lease_helpers.rb b/lib/gitlab/exclusive_lease_helpers.rb new file mode 100644 index 00000000000..ab6838adc6d --- /dev/null +++ b/lib/gitlab/exclusive_lease_helpers.rb @@ -0,0 +1,29 @@ +module Gitlab + # This module provides helper methods which are intregrated with GitLab::ExclusiveLease + module ExclusiveLeaseHelpers + FailedToObtainLockError = Class.new(StandardError) + + ## + # This helper method blocks a process/thread until the other process cancel the obrainted lease key. + # + # Note: It's basically discouraged to use this method in the unicorn's thread, + # because it holds the connection until all `retries` is consumed. + # This could potentially eat up all connection pools. + def in_lock(key, ttl: 1.minute, retries: 10, sleep_sec: 0.01.seconds) + lease = Gitlab::ExclusiveLease.new(key, timeout: ttl) + + until uuid = lease.try_obtain + # Keep trying until we obtain the lease. To prevent hammering Redis too + # much we'll wait for a bit. + sleep(sleep_sec) + break if (retries -= 1) < 0 + end + + raise FailedToObtainLockError, 'Failed to obtain a lock' unless uuid + + return yield + ensure + Gitlab::ExclusiveLease.cancel(key, uuid) + end + end +end diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index c67826da1d2..36d56e411d8 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -63,12 +63,8 @@ module Gitlab # This saves us an RPC round trip. return nil if commit_id.include?(':') - commit = repo.gitaly_migrate(:find_commit) do |is_enabled| - if is_enabled - repo.gitaly_commit_client.find_commit(commit_id) - else - rugged_find(repo, commit_id) - end + commit = repo.wrapped_gitaly_errors do + repo.gitaly_commit_client.find_commit(commit_id) end decorate(repo, commit) if commit @@ -78,12 +74,6 @@ module Gitlab nil end - def rugged_find(repo, commit_id) - obj = repo.rev_parse_target(commit_id) - - obj.is_a?(Rugged::Commit) ? obj : nil - end - # Get last commit for HEAD # # Ex. diff --git a/lib/gitlab/git/conflict/resolver.rb b/lib/gitlab/git/conflict/resolver.rb index c3cb0264112..0e4a973301f 100644 --- a/lib/gitlab/git/conflict/resolver.rb +++ b/lib/gitlab/git/conflict/resolver.rb @@ -12,14 +12,8 @@ module Gitlab end def conflicts - @conflicts ||= begin - @target_repository.gitaly_migrate(:conflicts_list_conflict_files) do |is_enabled| - if is_enabled - gitaly_conflicts_client(@target_repository).list_conflict_files.to_a - else - rugged_list_conflict_files - end - end + @conflicts ||= @target_repository.wrapped_gitaly_errors do + gitaly_conflicts_client(@target_repository).list_conflict_files.to_a end rescue GRPC::FailedPrecondition => e raise Gitlab::Git::Conflict::Resolver::ConflictSideMissing.new(e.message) @@ -28,12 +22,8 @@ module Gitlab end def resolve_conflicts(source_repository, resolution, source_branch:, target_branch:) - source_repository.gitaly_migrate(:conflicts_resolve_conflicts) do |is_enabled| - if is_enabled - gitaly_conflicts_client(source_repository).resolve_conflicts(@target_repository, resolution, source_branch, target_branch) - else - rugged_resolve_conflicts(source_repository, resolution, source_branch, target_branch) - end + source_repository.wrapped_gitaly_errors do + gitaly_conflicts_client(source_repository).resolve_conflicts(@target_repository, resolution, source_branch, target_branch) end end @@ -61,57 +51,6 @@ module Gitlab def gitaly_conflicts_client(repository) repository.gitaly_conflicts_client(@our_commit_oid, @their_commit_oid) end - - def write_resolved_file_to_index(repository, index, file, params) - if params[:sections] - resolved_lines = file.resolve_lines(params[:sections]) - new_file = resolved_lines.map { |line| line[:full_line] }.join("\n") - - new_file << "\n" if file.our_blob.data.end_with?("\n") - elsif params[:content] - new_file = file.resolve_content(params[:content]) - end - - our_path = file.our_path - - oid = repository.rugged.write(new_file, :blob) - index.add(path: our_path, oid: oid, mode: file.our_mode) - index.conflict_remove(our_path) - end - - def rugged_list_conflict_files - target_index = @target_repository.rugged.merge_commits(@our_commit_oid, @their_commit_oid) - - # We don't need to do `with_repo_branch_commit` here, because the target - # project always fetches source refs when creating merge request diffs. - conflict_files(@target_repository, target_index) - end - - def rugged_resolve_conflicts(source_repository, resolution, source_branch, target_branch) - source_repository.with_repo_branch_commit(@target_repository, target_branch) do - index = source_repository.rugged.merge_commits(@our_commit_oid, @their_commit_oid) - conflicts = conflict_files(source_repository, index) - - resolution.files.each do |file_params| - conflict_file = conflict_for_path(conflicts, file_params[:old_path], file_params[:new_path]) - - write_resolved_file_to_index(source_repository, index, conflict_file, file_params) - end - - unless index.conflicts.empty? - missing_files = index.conflicts.map { |file| file[:ours][:path] } - - raise ResolutionError, "Missing resolutions for the following files: #{missing_files.join(', ')}" - end - - commit_params = { - message: resolution.commit_message, - parents: [@our_commit_oid, @their_commit_oid] - } - - source_repository.commit_index(resolution.user, source_branch, index, commit_params) - end - end end end end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index bbfe6ab1d95..29b3663a52a 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -1115,8 +1115,18 @@ module Gitlab # This guard avoids Gitaly log/error spam raise NoRepository, 'repository does not exist' unless exists? + set_config('gitlab.fullpath' => full_path) + end + + def set_config(entries) + wrapped_gitaly_errors do + gitaly_repository_client.set_config(entries) + end + end + + def delete_config(*keys) wrapped_gitaly_errors do - gitaly_repository_client.write_config(full_path: full_path) + gitaly_repository_client.delete_config(keys) end end diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb index 982f8d0963b..64b9af4d70c 100644 --- a/lib/gitlab/gitaly_client/repository_service.rb +++ b/lib/gitlab/gitaly_client/repository_service.rb @@ -265,17 +265,39 @@ module Gitlab true end - def write_config(full_path:) - request = Gitaly::WriteConfigRequest.new(repository: @gitaly_repo, full_path: full_path) - response = GitalyClient.call( + def set_config(entries) + return if entries.empty? + + request = Gitaly::SetConfigRequest.new(repository: @gitaly_repo) + entries.each do |key, value| + request.entries << build_set_config_entry(key, value) + end + + GitalyClient.call( + @storage, + :repository_service, + :set_config, + request, + timeout: GitalyClient.fast_timeout + ) + + nil + end + + def delete_config(keys) + return if keys.empty? + + request = Gitaly::DeleteConfigRequest.new(repository: @gitaly_repo, keys: keys) + + GitalyClient.call( @storage, :repository_service, - :write_config, + :delete_config, request, timeout: GitalyClient.fast_timeout ) - raise Gitlab::Git::OSError.new(response.error) unless response.error.empty? + nil end def license_short_name @@ -352,6 +374,23 @@ module Gitlab timeout: timeout ) end + + def build_set_config_entry(key, value) + entry = Gitaly::SetConfigRequest::Entry.new(key: key) + + case value + when String + entry.value_str = value + when Integer + entry.value_int32 = value + when TrueClass, FalseClass + entry.value_bool = value + else + raise InvalidArgument, "invalid git config value: #{value.inspect}" + end + + entry + end end end end diff --git a/lib/gitlab/import_export.rb b/lib/gitlab/import_export.rb index 53fe2f8e436..be3710c5b7f 100644 --- a/lib/gitlab/import_export.rb +++ b/lib/gitlab/import_export.rb @@ -40,6 +40,10 @@ module Gitlab "#{basename[0..FILENAME_LIMIT]}_export.tar.gz" end + def object_storage? + Feature.enabled?(:import_export_object_storage) + end + def version VERSION end diff --git a/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy.rb b/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy.rb index aef371d81eb..83134bb0769 100644 --- a/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy.rb +++ b/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy.rb @@ -2,6 +2,7 @@ module Gitlab module ImportExport module AfterExportStrategies class BaseAfterExportStrategy + extend Gitlab::ImportExport::CommandLineUtil include ActiveModel::Validations extend Forwardable @@ -24,9 +25,10 @@ module Gitlab end def execute(current_user, project) - return unless project&.export_project_path - @project = project + + return unless @project.export_status == :finished + @current_user = current_user if invalid? @@ -51,9 +53,12 @@ module Gitlab end def self.lock_file_path(project) - return unless project&.export_path + return unless project.export_path || object_storage? - File.join(project.export_path, AFTER_EXPORT_LOCK_FILE_NAME) + lock_path = project.import_export_shared.archive_path + + mkdir_p(lock_path) + File.join(lock_path, AFTER_EXPORT_LOCK_FILE_NAME) end protected @@ -77,6 +82,10 @@ module Gitlab def log_validation_errors errors.full_messages.each { |msg| project.import_export_shared.add_error_message(msg) } end + + def object_storage? + project.export_project_object_exists? + end end end end diff --git a/lib/gitlab/import_export/after_export_strategies/web_upload_strategy.rb b/lib/gitlab/import_export/after_export_strategies/web_upload_strategy.rb index 938664a95a1..dce8f89c0ab 100644 --- a/lib/gitlab/import_export/after_export_strategies/web_upload_strategy.rb +++ b/lib/gitlab/import_export/after_export_strategies/web_upload_strategy.rb @@ -38,14 +38,20 @@ module Gitlab private def send_file - export_file = File.open(project.export_project_path) - - Gitlab::HTTP.public_send(http_method.downcase, url, send_file_options(export_file)) # rubocop:disable GitlabSecurity/PublicSend + Gitlab::HTTP.public_send(http_method.downcase, url, send_file_options) # rubocop:disable GitlabSecurity/PublicSend ensure - export_file.close if export_file + export_file.close if export_file && !object_storage? + end + + def export_file + if object_storage? + project.import_export_upload.export_file.file.open + else + File.open(project.export_project_path) + end end - def send_file_options(export_file) + def send_file_options { body_stream: export_file, headers: headers @@ -53,7 +59,15 @@ module Gitlab end def headers - { 'Content-Length' => File.size(project.export_project_path).to_s } + { 'Content-Length' => export_size.to_s } + end + + def export_size + if object_storage? + project.import_export_upload.export_file.file.size + else + File.size(project.export_project_path) + end end end end diff --git a/lib/gitlab/import_export/saver.rb b/lib/gitlab/import_export/saver.rb index 2daeba90a51..3cd153a4fd2 100644 --- a/lib/gitlab/import_export/saver.rb +++ b/lib/gitlab/import_export/saver.rb @@ -15,15 +15,22 @@ module Gitlab def save if compress_and_save remove_export_path + Rails.logger.info("Saved project export #{archive_file}") - archive_file + + save_on_object_storage if use_object_storage? else - @shared.error(Gitlab::ImportExport::Error.new("Unable to save #{archive_file} into #{@shared.export_path}")) + @shared.error(Gitlab::ImportExport::Error.new(error_message)) false end rescue => e @shared.error(e) false + ensure + if use_object_storage? + remove_archive + remove_export_path + end end private @@ -36,9 +43,29 @@ module Gitlab FileUtils.rm_rf(@shared.export_path) end + def remove_archive + FileUtils.rm_rf(@shared.archive_path) + end + def archive_file @archive_file ||= File.join(@shared.archive_path, Gitlab::ImportExport.export_filename(project: @project)) end + + def save_on_object_storage + upload = ImportExportUpload.find_or_initialize_by(project: @project) + + File.open(archive_file) { |file| upload.export_file = file } + + upload.save! + end + + def use_object_storage? + Gitlab::ImportExport.object_storage? + end + + def error_message + "Unable to save #{archive_file} into #{@shared.export_path}. Object storage enabled: #{use_object_storage?}" + end end end end diff --git a/lib/gitlab/middleware/read_only/controller.rb b/lib/gitlab/middleware/read_only/controller.rb index 4a99b7cca5c..8dca431c005 100644 --- a/lib/gitlab/middleware/read_only/controller.rb +++ b/lib/gitlab/middleware/read_only/controller.rb @@ -69,6 +69,7 @@ module Gitlab @route_hash ||= Rails.application.routes.recognize_path(request.url, { method: request.request_method }) rescue {} end + # Overridden in EE module def whitelisted_routes grack_route || ReadOnly.internal_routes.any? { |path| request.path.include?(path) } || lfs_route || sidekiq_route end diff --git a/lib/gitlab/omniauth_initializer.rb b/lib/gitlab/omniauth_initializer.rb index 35ed3a5ac05..a71acda8701 100644 --- a/lib/gitlab/omniauth_initializer.rb +++ b/lib/gitlab/omniauth_initializer.rb @@ -1,5 +1,10 @@ module Gitlab class OmniauthInitializer + def self.enabled? + Gitlab.config.omniauth.enabled || + Gitlab.config.omniauth.auto_sign_in_with_provider.present? + end + def initialize(devise_config) @devise_config = devise_config end diff --git a/package.json b/package.json index 6980416503e..26b87c70e98 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "webpack-prod": "NODE_ENV=production webpack --config config/webpack.config.js" }, "dependencies": { - "@gitlab-org/gitlab-svgs": "^1.24.0", + "@gitlab-org/gitlab-svgs": "^1.25.0", "autosize": "^4.0.0", "axios": "^0.17.1", "babel-core": "^6.26.3", @@ -66,7 +66,7 @@ "katex": "^0.8.3", "marked": "^0.3.12", "monaco-editor": "0.13.1", - "monaco-editor-webpack-plugin": "^1.2.1", + "monaco-editor-webpack-plugin": "^1.4.0", "mousetrap": "^1.4.6", "pikaday": "^1.6.1", "popper.js": "^1.14.3", @@ -40,6 +40,7 @@ module QA autoload :Issue, 'qa/factory/resource/issue' autoload :Project, 'qa/factory/resource/project' autoload :MergeRequest, 'qa/factory/resource/merge_request' + autoload :ProjectImportedFromGithub, 'qa/factory/resource/project_imported_from_github' autoload :DeployKey, 'qa/factory/resource/deploy_key' autoload :Branch, 'qa/factory/resource/branch' autoload :SecretVariable, 'qa/factory/resource/secret_variable' @@ -79,6 +80,7 @@ module QA autoload :Instance, 'qa/scenario/test/instance' module Integration + autoload :Github, 'qa/scenario/test/integration/github' autoload :LDAP, 'qa/scenario/test/integration/ldap' autoload :Kubernetes, 'qa/scenario/test/integration/kubernetes' autoload :Mattermost, 'qa/scenario/test/integration/mattermost' @@ -132,6 +134,10 @@ module QA autoload :Show, 'qa/page/project/show' autoload :Activity, 'qa/page/project/activity' + module Import + autoload :Github, 'qa/page/project/import/github' + end + module Pipeline autoload :Index, 'qa/page/project/pipeline/index' autoload :Show, 'qa/page/project/pipeline/show' @@ -184,6 +190,10 @@ module QA autoload :PersonalAccessTokens, 'qa/page/profile/personal_access_tokens' end + module Issuable + autoload :Sidebar, 'qa/page/issuable/sidebar' + end + module MergeRequest autoload :New, 'qa/page/merge_request/new' autoload :Show, 'qa/page/merge_request/show' @@ -206,6 +216,7 @@ module QA # module Component autoload :Dropzone, 'qa/page/component/dropzone' + autoload :Select2, 'qa/page/component/select2' end end diff --git a/qa/qa/factory/resource/group.rb b/qa/qa/factory/resource/group.rb index 9f13e26f35c..531fccd2ad8 100644 --- a/qa/qa/factory/resource/group.rb +++ b/qa/qa/factory/resource/group.rb @@ -23,7 +23,7 @@ module QA Page::Group::New.perform do |group| group.set_path(@path) group.set_description(@description) - group.set_visibility('Private') + group.set_visibility('Public') group.create end end diff --git a/qa/qa/factory/resource/project.rb b/qa/qa/factory/resource/project.rb index cda1b35ba6a..7bc64c6ae5d 100644 --- a/qa/qa/factory/resource/project.rb +++ b/qa/qa/factory/resource/project.rb @@ -5,16 +5,12 @@ module QA module Resource class Project < Factory::Base attr_writer :description + attr_reader :name dependency Factory::Resource::Group, as: :group - def name=(name) - @name = "#{name}-#{SecureRandom.hex(8)}" - @description = 'My awesome project' - end - - product :name do - Page::Project::Show.act { project_name } + product :name do |factory| + factory.name end product :repository_ssh_location do @@ -24,6 +20,14 @@ module QA end end + def initialize + @description = 'My awesome project' + end + + def name=(raw_name) + @name = "#{raw_name}-#{SecureRandom.hex(8)}" + end + def fabricate! group.visit! diff --git a/qa/qa/factory/resource/project_imported_from_github.rb b/qa/qa/factory/resource/project_imported_from_github.rb new file mode 100644 index 00000000000..df2a3340d60 --- /dev/null +++ b/qa/qa/factory/resource/project_imported_from_github.rb @@ -0,0 +1,37 @@ +require 'securerandom' + +module QA + module Factory + module Resource + class ProjectImportedFromGithub < Resource::Project + attr_writer :personal_access_token, :github_repository_path + + dependency Factory::Resource::Group, as: :group + + product :name do |factory| + factory.name + end + + def fabricate! + group.visit! + + Page::Group::Show.act { go_to_new_project } + + Page::Project::New.perform do |page| + page.go_to_import_project + end + + Page::Project::New.perform do |page| + page.go_to_github_import + end + + Page::Project::Import::Github.perform do |page| + page.add_personal_access_token(@personal_access_token) + page.list_repos + page.import!(@github_repository_path, @name) + end + end + end + end + end +end diff --git a/qa/qa/factory/resource/sandbox.rb b/qa/qa/factory/resource/sandbox.rb index ad376988e82..4f6039f300f 100644 --- a/qa/qa/factory/resource/sandbox.rb +++ b/qa/qa/factory/resource/sandbox.rb @@ -21,8 +21,8 @@ module QA Page::Group::New.perform do |group| group.set_path(@name) - group.set_description('GitLab QA Sandbox') - group.set_visibility('Private') + group.set_description('GitLab QA Sandbox Group') + group.set_visibility('Public') group.create end end diff --git a/qa/qa/page/component/select2.rb b/qa/qa/page/component/select2.rb new file mode 100644 index 00000000000..30829eb0221 --- /dev/null +++ b/qa/qa/page/component/select2.rb @@ -0,0 +1,11 @@ +module QA + module Page + module Component + module Select2 + def select_item(item_text) + find('ul.select2-result-sub > li', text: item_text).click + end + end + end + end +end diff --git a/qa/qa/page/issuable/sidebar.rb b/qa/qa/page/issuable/sidebar.rb new file mode 100644 index 00000000000..dec2ce1eab3 --- /dev/null +++ b/qa/qa/page/issuable/sidebar.rb @@ -0,0 +1,17 @@ +module QA + module Page + module Issuable + class Sidebar < Page::Base + view 'app/views/shared/issuable/_sidebar.html.haml' do + element :labels_block, ".issuable-show-labels" + end + + def has_label?(label) + page.within('.issuable-show-labels') do + !!find('span', text: label) + end + end + end + end + end +end diff --git a/qa/qa/page/menu/main.rb b/qa/qa/page/menu/main.rb index fda9c45c091..aef5c9f9c82 100644 --- a/qa/qa/page/menu/main.rb +++ b/qa/qa/page/menu/main.rb @@ -16,7 +16,7 @@ module QA view 'app/views/layouts/nav/_dashboard.html.haml' do element :admin_area_link element :projects_dropdown - element :groups_link + element :groups_dropdown end view 'app/views/layouts/nav/projects_dropdown/_show.html.haml' do @@ -25,7 +25,13 @@ module QA end def go_to_groups - within_top_menu { click_element :groups_link } + within_top_menu do + click_element :groups_dropdown + end + + page.within('.qa-groups-dropdown-sidebar') do + click_element :your_groups_link + end end def go_to_projects diff --git a/qa/qa/page/menu/side.rb b/qa/qa/page/menu/side.rb index 6bf4825cf00..333d871c51a 100644 --- a/qa/qa/page/menu/side.rb +++ b/qa/qa/page/menu/side.rb @@ -10,6 +10,8 @@ module QA element :operations_kubernetes_link, "title: _('Kubernetes')" element :issues_link, /link_to.*shortcuts-issues/ element :issues_link_text, "Issues" + element :merge_requests_link, /link_to.*shortcuts-merge_requests/ + element :merge_requests_link_text, "Merge Requests" element :top_level_items, '.sidebar-top-level-items' element :operations_section, "class: 'shortcuts-operations'" element :activity_link, "title: 'Activity'" @@ -62,6 +64,12 @@ module QA end end + def click_merge_requests + within_sidebar do + click_link('Merge Requests') + end + end + def click_wiki within_sidebar do click_link('Wiki') diff --git a/qa/qa/page/project/import/github.rb b/qa/qa/page/project/import/github.rb new file mode 100644 index 00000000000..36567927194 --- /dev/null +++ b/qa/qa/page/project/import/github.rb @@ -0,0 +1,66 @@ +module QA + module Page + module Project + module Import + class Github < Page::Base + include Page::Component::Select2 + + view 'app/views/import/github/new.html.haml' do + element :personal_access_token_field, 'text_field_tag :personal_access_token' + element :list_repos_button, "submit_tag _('List your GitHub repositories')" + end + + view 'app/views/import/_githubish_status.html.haml' do + element :project_import_row, 'data: { qa: { repo_path: repo.full_name } }' + element :project_namespace_select + element :project_namespace_field, 'select_tag :namespace_id' + element :project_path_field, 'text_field_tag :path, repo.name' + element :import_button, "_('Import')" + end + + def add_personal_access_token(personal_access_token) + fill_in 'personal_access_token', with: personal_access_token + end + + def list_repos + click_button 'List your GitHub repositories' + end + + def import!(full_path, name) + choose_test_namespace(full_path) + set_path(full_path, name) + import_project(full_path) + end + + private + + def within_repo_path(full_path) + page.within(%Q(tr[data-qa-repo-path="#{full_path}"])) do + yield + end + end + + def choose_test_namespace(full_path) + within_repo_path(full_path) do + click_element :project_namespace_select + end + + select_item(Runtime::Namespace.path) + end + + def set_path(full_path, name) + within_repo_path(full_path) do + fill_in 'path', with: name + end + end + + def import_project(full_path) + within_repo_path(full_path) do + click_button 'Import' + end + end + end + end + end + end +end diff --git a/qa/qa/page/project/new.rb b/qa/qa/page/project/new.rb index 186a4724326..7976e96d43b 100644 --- a/qa/qa/page/project/new.rb +++ b/qa/qa/page/project/new.rb @@ -2,6 +2,12 @@ module QA module Page module Project class New < Page::Base + include Page::Component::Select2 + + view 'app/views/projects/new.html.haml' do + element :import_project_tab, "Import project" + end + view 'app/views/projects/_new_project_fields.html.haml' do element :project_namespace_select element :project_namespace_field, /select :namespace_id.*class: 'select2/ @@ -10,10 +16,18 @@ module QA element :project_create_button, "submit 'Create project'" end + view 'app/views/projects/_import_project_pane.html.haml' do + element :import_github, "icon('github', text: 'GitHub')" + end + def choose_test_namespace click_element :project_namespace_select - find('ul.select2-result-sub > li', text: Runtime::Namespace.path).click + select_item(Runtime::Namespace.path) + end + + def go_to_import_project + click_on 'Import project' end def choose_name(name) @@ -27,6 +41,10 @@ module QA def create_new_project click_on 'Create project' end + + def go_to_github_import + click_link 'GitHub' + end end end end diff --git a/qa/qa/page/project/show.rb b/qa/qa/page/project/show.rb index 1406edece17..1dcdb59490a 100644 --- a/qa/qa/page/project/show.rb +++ b/qa/qa/page/project/show.rb @@ -22,6 +22,10 @@ module QA element :branches_dropdown end + view 'app/views/projects/_files.html.haml' do + element :tree_holder, '.tree-holder' + end + def project_name find('.qa-project-name').text end @@ -46,6 +50,12 @@ module QA click_element :create_merge_request end + def wait_for_import + wait(reload: true) do + has_css?('.tree-holder') + end + end + def go_to_new_issue click_element :new_menu_toggle diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb index 7610c7f3f43..5dc194e0aef 100644 --- a/qa/qa/runtime/env.rb +++ b/qa/qa/runtime/env.rb @@ -66,6 +66,17 @@ module QA def has_gcloud_credentials? %w[GCLOUD_ACCOUNT_KEY GCLOUD_ACCOUNT_EMAIL].none? { |var| ENV[var].to_s.empty? } end + + # Specifies the token that can be used for the GitHub API + def github_access_token + ENV['GITHUB_ACCESS_TOKEN'].to_s.strip + end + + def require_github_access_token! + return unless github_access_token.empty? + + raise ArgumentError, "Please provide GITHUB_ACCESS_TOKEN" + end end end end diff --git a/qa/qa/runtime/namespace.rb b/qa/qa/runtime/namespace.rb index 8d05b387416..ccfa8b44db3 100644 --- a/qa/qa/runtime/namespace.rb +++ b/qa/qa/runtime/namespace.rb @@ -16,7 +16,7 @@ module QA end def sandbox_name - Runtime::Env.sandbox_name || 'gitlab-qa-sandbox' + Runtime::Env.sandbox_name || 'gitlab-qa-sandbox-group' end end end diff --git a/qa/qa/scenario/test/integration/github.rb b/qa/qa/scenario/test/integration/github.rb new file mode 100644 index 00000000000..1d22b532aa5 --- /dev/null +++ b/qa/qa/scenario/test/integration/github.rb @@ -0,0 +1,18 @@ +module QA + module Scenario + module Test + module Integration + class Github < Test::Instance + tags :github + + def perform(address, *rspec_options) + # This test suite requires a GitHub personal access token + Runtime::Env.require_github_access_token! + + super + end + end + end + end + end +end diff --git a/qa/qa/specs/features/project/import_from_github_spec.rb b/qa/qa/specs/features/project/import_from_github_spec.rb new file mode 100644 index 00000000000..221b5c27fba --- /dev/null +++ b/qa/qa/specs/features/project/import_from_github_spec.rb @@ -0,0 +1,106 @@ +module QA + describe 'user imports a GitHub repo', :core, :github do + let(:imported_project) do + Factory::Resource::ProjectImportedFromGithub.fabricate! do |project| + project.name = 'imported-project' + project.personal_access_token = Runtime::Env.github_access_token + project.github_repository_path = 'gitlab-qa/test-project' + end + end + + after do + # We need to delete the imported project because it's impossible to import + # the same GitHub project twice for a given user. + api_client = Runtime::API::Client.new(:gitlab) + delete_project_request = Runtime::API::Request.new(api_client, "/projects/#{CGI.escape("#{Runtime::Namespace.path}/#{imported_project.name}")}") + delete delete_project_request.url + + expect_status(202) + end + + it 'user imports a GitHub repo' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.act { sign_in_using_credentials } + + imported_project # import the project + + Page::Menu::Main.act { go_to_projects } + Page::Dashboard::Projects.perform do |dashboard| + dashboard.go_to_project(imported_project.name) + end + + Page::Project::Show.act { wait_for_import } + + verify_repository_import + verify_issues_import + verify_merge_requests_import + verify_labels_import + verify_milestones_import + verify_wiki_import + end + + def verify_repository_import + expect(page).to have_content('This test project is used for automated GitHub import by GitLab QA.') + expect(page).to have_content(imported_project.name) + end + + def verify_issues_import + Page::Menu::Side.act { click_issues } + expect(page).to have_content('This is a sample issue') + + click_link 'This is a sample issue' + + expect(page).to have_content('We should populate this project with issues, pull requests and wiki pages.') + + # Comments + expect(page).to have_content('This is a comment from @rymai.') + + Page::Issuable::Sidebar.perform do |issuable| + expect(issuable).to have_label('enhancement') + expect(issuable).to have_label('help wanted') + expect(issuable).to have_label('good first issue') + end + end + + def verify_merge_requests_import + Page::Menu::Side.act { click_merge_requests } + expect(page).to have_content('Improve README.md') + + click_link 'Improve README.md' + + expect(page).to have_content('This improves the README file a bit.') + + # Review comment are not supported yet + expect(page).not_to have_content('Really nice change.') + + # Comments + expect(page).to have_content('Nice work! This is a comment from @rymai.') + + # Diff comments + expect(page).to have_content('[Review comment] I like that!') + expect(page).to have_content('[Review comment] Nice blank line.') + expect(page).to have_content('[Single diff comment] Much better without this line!') + + Page::Issuable::Sidebar.perform do |issuable| + expect(issuable).to have_label('bug') + expect(issuable).to have_label('enhancement') + end + end + + def verify_labels_import + # TODO: Waiting on https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19228 + # to build upon it. + end + + def verify_milestones_import + # TODO: Waiting on https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/18727 + # to build upon it. + end + + def verify_wiki_import + Page::Menu::Side.act { click_wiki } + + expect(page).to have_content('Welcome to the test-project wiki!') + end + end +end diff --git a/qa/spec/runtime/env_spec.rb b/qa/spec/runtime/env_spec.rb index 2b6365dbc41..851026c71f0 100644 --- a/qa/spec/runtime/env_spec.rb +++ b/qa/spec/runtime/env_spec.rb @@ -76,4 +76,27 @@ describe QA::Runtime::Env do expect { described_class.user_type }.to raise_error(ArgumentError) end end + + describe '.github_access_token' do + it 'returns "" if GITHUB_ACCESS_TOKEN is not defined' do + expect(described_class.github_access_token).to eq('') + end + + it 'returns stripped string if GITHUB_ACCESS_TOKEN is defined' do + stub_env('GITHUB_ACCESS_TOKEN', ' abc123 ') + expect(described_class.github_access_token).to eq('abc123') + end + end + + describe '.require_github_access_token!' do + it 'raises ArgumentError if GITHUB_ACCESS_TOKEN is not defined' do + expect { described_class.require_github_access_token! }.to raise_error(ArgumentError) + end + + it 'does not raise if GITHUB_ACCESS_TOKEN is defined' do + stub_env('GITHUB_ACCESS_TOKEN', ' abc123 ') + + expect { described_class.require_github_access_token! }.not_to raise_error + end + end end diff --git a/spec/controllers/concerns/group_tree_spec.rb b/spec/controllers/concerns/group_tree_spec.rb index ba84fbf8564..503eb416962 100644 --- a/spec/controllers/concerns/group_tree_spec.rb +++ b/spec/controllers/concerns/group_tree_spec.rb @@ -63,6 +63,17 @@ describe GroupTree do expect(assigns(:groups)).to contain_exactly(parent, subgroup) end + + it 'preloads parents regardless of pagination' do + allow(Kaminari.config).to receive(:default_per_page).and_return(1) + group = create(:group, :public) + subgroup = create(:group, :public, parent: group) + search_result = create(:group, :public, name: 'result', parent: subgroup) + + get :index, filter: 'resu', format: :json + + expect(assigns(:groups)).to contain_exactly(group, subgroup, search_result) + end end context 'json content' do diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 34ed835a388..a2dfc43e9f7 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -790,23 +790,55 @@ describe ProjectsController do project.add_master(user) end - context 'when project export is enabled' do - it 'returns 302' do - get :download_export, namespace_id: project.namespace, id: project + context 'object storage disabled' do + before do + stub_feature_flags(import_export_object_storage: false) + end - expect(response).to have_gitlab_http_status(302) + context 'when project export is enabled' do + it 'returns 302' do + get :download_export, namespace_id: project.namespace, id: project + + expect(response).to have_gitlab_http_status(302) + end + end + + context 'when project export is disabled' do + before do + stub_application_setting(project_export_enabled?: false) + end + + it 'returns 404' do + get :download_export, namespace_id: project.namespace, id: project + + expect(response).to have_gitlab_http_status(404) + end end end - context 'when project export is disabled' do + context 'object storage enabled' do before do - stub_application_setting(project_export_enabled?: false) + stub_feature_flags(import_export_object_storage: true) end - it 'returns 404' do - get :download_export, namespace_id: project.namespace, id: project + context 'when project export is enabled' do + it 'returns 302' do + get :download_export, namespace_id: project.namespace, id: project - expect(response).to have_gitlab_http_status(404) + expect(response).to have_gitlab_http_status(302) + end + end + + context 'when project export is disabled' do + before do + stub_application_setting(project_export_enabled?: false) + end + + it 'returns 404' do + get :download_export, namespace_id: project.namespace, id: project + + expect(response).to have_gitlab_http_status(404) + end end end end diff --git a/spec/factories/ci/build_trace_chunks.rb b/spec/factories/ci/build_trace_chunks.rb index c0b9a25bfe8..3e8e2736423 100644 --- a/spec/factories/ci/build_trace_chunks.rb +++ b/spec/factories/ci/build_trace_chunks.rb @@ -3,5 +3,53 @@ FactoryBot.define do build factory: :ci_build chunk_index 0 data_store :redis + + trait :redis_with_data do + data_store :redis + + transient do + initial_data 'test data' + end + + after(:create) do |build_trace_chunk, evaluator| + Ci::BuildTraceChunks::Redis.new.set_data(build_trace_chunk, evaluator.initial_data) + end + end + + trait :redis_without_data do + data_store :redis + end + + trait :database_with_data do + data_store :database + + transient do + initial_data 'test data' + end + + after(:build) do |build_trace_chunk, evaluator| + Ci::BuildTraceChunks::Database.new.set_data(build_trace_chunk, evaluator.initial_data) + end + end + + trait :database_without_data do + data_store :database + end + + trait :fog_with_data do + data_store :fog + + transient do + initial_data 'test data' + end + + after(:create) do |build_trace_chunk, evaluator| + Ci::BuildTraceChunks::Fog.new.set_data(build_trace_chunk, evaluator.initial_data) + end + end + + trait :fog_without_data do + data_store :fog + end end end diff --git a/spec/factories/import_export_uploads.rb b/spec/factories/import_export_uploads.rb new file mode 100644 index 00000000000..7750d49b1d0 --- /dev/null +++ b/spec/factories/import_export_uploads.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :import_export_upload do + project { create(:project) } + end +end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index f6b05bac0e8..f77ded23b18 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -103,6 +103,22 @@ FactoryBot.define do end trait :with_export do + before(:create) do |_project, _evaluator| + allow(Feature).to receive(:enabled?).with(:import_export_object_storage) { false } + allow(Feature).to receive(:enabled?).with('import_export_object_storage') { false } + end + + after(:create) do |project, _evaluator| + ProjectExportWorker.new.perform(project.creator.id, project.id) + end + end + + trait :with_object_export do + before(:create) do |_project, _evaluator| + allow(Feature).to receive(:enabled?).with(:import_export_object_storage) { true } + allow(Feature).to receive(:enabled?).with('import_export_object_storage') { true } + end + after(:create) do |project, evaluator| ProjectExportWorker.new.perform(project.creator.id, project.id) end diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb index bfb17a56613..e6586fc8a0a 100644 --- a/spec/features/profiles/keys_spec.rb +++ b/spec/features/profiles/keys_spec.rb @@ -30,6 +30,20 @@ describe 'Profile > SSH Keys' do expect(find('.breadcrumbs-sub-title')).to have_link(attrs[:title]) end + it 'shows a confirmable warning if the key does not start with ssh-' do + attrs = attributes_for(:key) + + fill_in('Key', with: 'invalid-key') + fill_in('Title', with: attrs[:title]) + click_button('Add key') + + expect(page).to have_selector('.js-add-ssh-key-validation-warning') + + find('.js-add-ssh-key-validation-confirm-submit').click + + expect(page).to have_content('Key is invalid') + end + context 'when only DSA and ECDSA keys are allowed' do before do forbidden = ApplicationSetting::FORBIDDEN_KEY_VALUE diff --git a/spec/features/projects/import_export/export_file_spec.rb b/spec/features/projects/import_export/export_file_spec.rb index 8a418356541..eb281cd2122 100644 --- a/spec/features/projects/import_export/export_file_spec.rb +++ b/spec/features/projects/import_export/export_file_spec.rb @@ -25,6 +25,7 @@ describe 'Import/Export - project export integration test', :js do before do allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) + stub_feature_flags(import_export_object_storage: false) end after do diff --git a/spec/features/projects/import_export/namespace_export_file_spec.rb b/spec/features/projects/import_export/namespace_export_file_spec.rb index 7d056b0c140..9bb8a2063b5 100644 --- a/spec/features/projects/import_export/namespace_export_file_spec.rb +++ b/spec/features/projects/import_export/namespace_export_file_spec.rb @@ -5,6 +5,7 @@ describe 'Import/Export - Namespace export file cleanup', :js do before do allow(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) + stub_feature_flags(import_export_object_storage: false) end after do diff --git a/spec/features/projects/import_export/test_project_export.tar.gz b/spec/features/projects/import_export/test_project_export.tar.gz Binary files differindex ceba4dfec57..3b5df47e0b6 100644 --- a/spec/features/projects/import_export/test_project_export.tar.gz +++ b/spec/features/projects/import_export/test_project_export.tar.gz diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb index ef15e7e1ff9..0bec5f185d6 100644 --- a/spec/features/projects/wiki/markdown_preview_spec.rb +++ b/spec/features/projects/wiki/markdown_preview_spec.rb @@ -9,6 +9,7 @@ describe 'Projects > Wiki > User previews markdown changes', :js do [relative link 1](../relative) [relative link 2](./relative) [relative link 3](./e/f/relative) +[spaced link](title with spaces) HEREDOC end @@ -42,6 +43,7 @@ describe 'Projects > Wiki > User previews markdown changes', :js do expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a/b/relative\">relative link 1</a>") expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a/b/c/relative\">relative link 2</a>") expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a/b/c/e/f/relative\">relative link 3</a>") + expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/title%20with%20spaces\">spaced link</a>") end end @@ -64,6 +66,7 @@ describe 'Projects > Wiki > User previews markdown changes', :js do expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/relative\">relative link 1</a>") expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>") expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>") + expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/title%20with%20spaces\">spaced link</a>") end end @@ -86,6 +89,7 @@ describe 'Projects > Wiki > User previews markdown changes', :js do expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/relative\">relative link 1</a>") expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>") expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>") + expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/title%20with%20spaces\">spaced link</a>") end end end @@ -119,6 +123,7 @@ describe 'Projects > Wiki > User previews markdown changes', :js do expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a/b/relative\">relative link 1</a>") expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a/b/c/relative\">relative link 2</a>") expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a/b/c/e/f/relative\">relative link 3</a>") + expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/title%20with%20spaces\">spaced link</a>") end end @@ -136,6 +141,7 @@ describe 'Projects > Wiki > User previews markdown changes', :js do expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/relative\">relative link 1</a>") expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>") expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>") + expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/title%20with%20spaces\">spaced link</a>") end end @@ -153,6 +159,7 @@ describe 'Projects > Wiki > User previews markdown changes', :js do expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/relative\">relative link 1</a>") expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>") expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>") + expect(page.html).to include("<a href=\"/#{project.full_path}/wikis/title%20with%20spaces\">spaced link</a>") end end end diff --git a/spec/features/snippets/show_spec.rb b/spec/features/snippets/show_spec.rb index 74b693f3eff..f31457db92f 100644 --- a/spec/features/snippets/show_spec.rb +++ b/spec/features/snippets/show_spec.rb @@ -68,6 +68,26 @@ describe 'Snippet', :js do end end + context 'with cached Redcarpet html' do + let(:snippet) { create(:personal_snippet, :public, file_name: file_name, content: content, cached_markdown_version: CacheMarkdownField::CACHE_REDCARPET_VERSION) } + let(:file_name) { 'test.md' } + let(:content) { "1. one\n - sublist\n" } + + it 'renders correctly' do + expect(page).to have_xpath("//ol//li//ul") + end + end + + context 'with cached CommonMark html' do + let(:snippet) { create(:personal_snippet, :public, file_name: file_name, content: content, cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION) } + let(:file_name) { 'test.md' } + let(:content) { "1. one\n - sublist\n" } + + it 'renders correctly' do + expect(page).not_to have_xpath("//ol//li//ul") + end + end + context 'switching to the simple viewer' do before do find('.js-blob-viewer-switch-btn[data-viewer=simple]').click diff --git a/spec/fixtures/project_export.tar.gz b/spec/fixtures/project_export.tar.gz Binary files differnew file mode 100644 index 00000000000..72ab2d71f35 --- /dev/null +++ b/spec/fixtures/project_export.tar.gz diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index 6c94bd4e504..115807f954b 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -206,8 +206,9 @@ describe GroupsHelper do let(:group) { create(:group, :public) } let(:user) { create(:user) } before do + group.add_owner(user) allow(helper).to receive(:current_user) { user } - allow(helper).to receive(:can?) { true } + allow(helper).to receive(:can?) { |*args| Ability.allowed?(*args) } helper.instance_variable_set(:@group, group) end @@ -231,7 +232,10 @@ describe GroupsHelper do cross_project_features = [:activity, :issues, :labels, :milestones, :merge_requests] - expect(helper).to receive(:can?).with(user, :read_cross_project) { false } + allow(Ability).to receive(:allowed?).and_call_original + cross_project_features.each do |feature| + expect(Ability).to receive(:allowed?).with(user, "read_group_#{feature}".to_sym, group) { false } + end expect(helper.group_sidebar_links).not_to include(*cross_project_features) end diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index d246beb9888..f76ed4bfda4 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -184,6 +184,7 @@ describe IssuablesHelper do issuableRef: "##{issue.iid}", markdownPreviewPath: "/#{@project.full_path}/preview_markdown", markdownDocsPath: '/help/user/markdown', + markdownVersion: 11, issuableTemplates: [], projectPath: @project.path, projectNamespace: @project.namespace.path, diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb index 1a720aae55c..d5ed5c59c61 100644 --- a/spec/helpers/markup_helper_spec.rb +++ b/spec/helpers/markup_helper_spec.rb @@ -205,7 +205,9 @@ describe MarkupHelper do it "uses Wiki pipeline for markdown files" do allow(@wiki).to receive(:format).and_return(:markdown) - expect(helper).to receive(:markdown_unsafe).with('wiki content', pipeline: :wiki, project: project, project_wiki: @wiki, page_slug: "nested/page", issuable_state_filter_enabled: true) + expect(helper).to receive(:markdown_unsafe).with('wiki content', + pipeline: :wiki, project: project, project_wiki: @wiki, page_slug: "nested/page", + issuable_state_filter_enabled: true, markdown_engine: :redcarpet) helper.render_wiki_content(@wiki) end @@ -236,19 +238,32 @@ describe MarkupHelper do expect(helper.markup('foo.rst', content).encoding.name).to eq('UTF-8') end - it "delegates to #markdown_unsafe when file name corresponds to Markdown" do + it 'delegates to #markdown_unsafe when file name corresponds to Markdown' do expect(helper).to receive(:gitlab_markdown?).with('foo.md').and_return(true) expect(helper).to receive(:markdown_unsafe).and_return('NOEL') expect(helper.markup('foo.md', content)).to eq('NOEL') end - it "delegates to #asciidoc_unsafe when file name corresponds to AsciiDoc" do + it 'delegates to #asciidoc_unsafe when file name corresponds to AsciiDoc' do expect(helper).to receive(:asciidoc?).with('foo.adoc').and_return(true) expect(helper).to receive(:asciidoc_unsafe).and_return('NOEL') expect(helper.markup('foo.adoc', content)).to eq('NOEL') end + + it 'uses passed in rendered content' do + expect(helper).not_to receive(:gitlab_markdown?) + expect(helper).not_to receive(:markdown_unsafe) + + expect(helper.markup('foo.md', content, rendered: '<p>NOEL</p>')).to eq('<p>NOEL</p>') + end + + it 'defaults to Redcarpet' do + expect(helper).to receive(:markdown_unsafe).with(content, hash_including(markdown_engine: :redcarpet)).and_return('NOEL') + + expect(helper.markup('foo.md', content)).to eq('NOEL') + end end describe '#first_line_in_markdown' do diff --git a/spec/javascripts/diffs/store/getters_spec.js b/spec/javascripts/diffs/store/getters_spec.js index 7945ddea911..7a94f18778b 100644 --- a/spec/javascripts/diffs/store/getters_spec.js +++ b/spec/javascripts/diffs/store/getters_spec.js @@ -1,24 +1,66 @@ -import getters from '~/diffs/store/getters'; +import * as getters from '~/diffs/store/getters'; +import state from '~/diffs/store/modules/diff_state'; import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants'; describe('DiffsStoreGetters', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + describe('isParallelView', () => { it('should return true if view set to parallel view', () => { - expect(getters.isParallelView({ diffViewType: PARALLEL_DIFF_VIEW_TYPE })).toBeTruthy(); + localState.diffViewType = PARALLEL_DIFF_VIEW_TYPE; + + expect(getters.isParallelView(localState)).toEqual(true); }); it('should return false if view not to parallel view', () => { - expect(getters.isParallelView({ diffViewType: 'foo' })).toBeFalsy(); + localState.diffViewType = INLINE_DIFF_VIEW_TYPE; + + expect(getters.isParallelView(localState)).toEqual(false); }); }); describe('isInlineView', () => { it('should return true if view set to inline view', () => { - expect(getters.isInlineView({ diffViewType: INLINE_DIFF_VIEW_TYPE })).toBeTruthy(); + localState.diffViewType = INLINE_DIFF_VIEW_TYPE; + + expect(getters.isInlineView(localState)).toEqual(true); }); it('should return false if view not to inline view', () => { - expect(getters.isInlineView({ diffViewType: PARALLEL_DIFF_VIEW_TYPE })).toBeFalsy(); + localState.diffViewType = PARALLEL_DIFF_VIEW_TYPE; + + expect(getters.isInlineView(localState)).toEqual(false); + }); + }); + + describe('areAllFilesCollapsed', () => { + it('returns true when all files are collapsed', () => { + localState.diffFiles = [{ collapsed: true }, { collapsed: true }]; + expect(getters.areAllFilesCollapsed(localState)).toEqual(true); + }); + + it('returns false when at least one file is not collapsed', () => { + localState.diffFiles = [{ collapsed: false }, { collapsed: true }]; + expect(getters.areAllFilesCollapsed(localState)).toEqual(false); + }); + }); + + describe('commitId', () => { + it('returns commit id when is set', () => { + const commitID = '800f7a91'; + localState.commit = { + id: commitID, + }; + + expect(getters.commitId(localState)).toEqual(commitID); + }); + + it('returns null when no commit is set', () => { + expect(getters.commitId(localState)).toEqual(null); }); }); }); diff --git a/spec/javascripts/frequent_items/components/app_spec.js b/spec/javascripts/frequent_items/components/app_spec.js new file mode 100644 index 00000000000..834f919524d --- /dev/null +++ b/spec/javascripts/frequent_items/components/app_spec.js @@ -0,0 +1,251 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import Vue from 'vue'; +import appComponent from '~/frequent_items/components/app.vue'; +import eventHub from '~/frequent_items/event_hub'; +import store from '~/frequent_items/store'; +import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants'; +import { getTopFrequentItems } from '~/frequent_items/utils'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data'; + +let session; +const createComponentWithStore = (namespace = 'projects') => { + session = currentSession[namespace]; + gon.api_version = session.apiVersion; + const Component = Vue.extend(appComponent); + + return mountComponentWithStore(Component, { + store, + props: { + namespace, + currentUserName: session.username, + currentItem: session.project || session.group, + }, + }); +}; + +describe('Frequent Items App Component', () => { + let vm; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + vm = createComponentWithStore(); + }); + + afterEach(() => { + mock.restore(); + vm.$destroy(); + }); + + describe('methods', () => { + describe('dropdownOpenHandler', () => { + it('should fetch frequent items when no search has been previously made on desktop', () => { + spyOn(vm, 'fetchFrequentItems'); + + vm.dropdownOpenHandler(); + + expect(vm.fetchFrequentItems).toHaveBeenCalledWith(); + }); + }); + + describe('logItemAccess', () => { + let storage; + + beforeEach(() => { + storage = {}; + + spyOn(window.localStorage, 'setItem').and.callFake((storageKey, value) => { + storage[storageKey] = value; + }); + + spyOn(window.localStorage, 'getItem').and.callFake(storageKey => { + if (storage[storageKey]) { + return storage[storageKey]; + } + + return null; + }); + }); + + it('should create a project store if it does not exist and adds a project', () => { + vm.logItemAccess(session.storageKey, session.project); + + const projects = JSON.parse(storage[session.storageKey]); + + expect(projects.length).toBe(1); + expect(projects[0].frequency).toBe(1); + expect(projects[0].lastAccessedOn).toBeDefined(); + }); + + it('should prevent inserting same report multiple times into store', () => { + vm.logItemAccess(session.storageKey, session.project); + vm.logItemAccess(session.storageKey, session.project); + + const projects = JSON.parse(storage[session.storageKey]); + + expect(projects.length).toBe(1); + }); + + it('should increase frequency of report if it was logged multiple times over the course of an hour', () => { + let projects; + const newTimestamp = Date.now() + HOUR_IN_MS + 1; + + vm.logItemAccess(session.storageKey, session.project); + projects = JSON.parse(storage[session.storageKey]); + + expect(projects[0].frequency).toBe(1); + + vm.logItemAccess(session.storageKey, { + ...session.project, + lastAccessedOn: newTimestamp, + }); + projects = JSON.parse(storage[session.storageKey]); + + expect(projects[0].frequency).toBe(2); + expect(projects[0].lastAccessedOn).not.toBe(session.project.lastAccessedOn); + }); + + it('should always update project metadata', () => { + let projects; + const oldProject = { + ...session.project, + }; + + const newProject = { + ...session.project, + name: 'New Name', + avatarUrl: 'new/avatar.png', + namespace: 'New / Namespace', + webUrl: 'http://localhost/new/web/url', + }; + + vm.logItemAccess(session.storageKey, oldProject); + projects = JSON.parse(storage[session.storageKey]); + + expect(projects[0].name).toBe(oldProject.name); + expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl); + expect(projects[0].namespace).toBe(oldProject.namespace); + expect(projects[0].webUrl).toBe(oldProject.webUrl); + + vm.logItemAccess(session.storageKey, newProject); + projects = JSON.parse(storage[session.storageKey]); + + expect(projects[0].name).toBe(newProject.name); + expect(projects[0].avatarUrl).toBe(newProject.avatarUrl); + expect(projects[0].namespace).toBe(newProject.namespace); + expect(projects[0].webUrl).toBe(newProject.webUrl); + }); + + it('should not add more than 20 projects in store', () => { + for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT; id += 1) { + const project = { + ...session.project, + id, + }; + vm.logItemAccess(session.storageKey, project); + } + + const projects = JSON.parse(storage[session.storageKey]); + + expect(projects.length).toBe(FREQUENT_ITEMS.MAX_COUNT); + }); + }); + }); + + describe('created', () => { + it('should bind event listeners on eventHub', done => { + spyOn(eventHub, '$on'); + + createComponentWithStore().$mount(); + + Vue.nextTick(() => { + expect(eventHub.$on).toHaveBeenCalledWith('projects-dropdownOpen', jasmine.any(Function)); + done(); + }); + }); + }); + + describe('beforeDestroy', () => { + it('should unbind event listeners on eventHub', done => { + spyOn(eventHub, '$off'); + + vm.$mount(); + vm.$destroy(); + + Vue.nextTick(() => { + expect(eventHub.$off).toHaveBeenCalledWith('projects-dropdownOpen', jasmine.any(Function)); + done(); + }); + }); + }); + + describe('template', () => { + it('should render search input', () => { + expect(vm.$el.querySelector('.search-input-container')).toBeDefined(); + }); + + it('should render loading animation', done => { + vm.$store.dispatch('fetchSearchedItems'); + + Vue.nextTick(() => { + const loadingEl = vm.$el.querySelector('.loading-animation'); + + expect(loadingEl).toBeDefined(); + expect(loadingEl.classList.contains('prepend-top-20')).toBe(true); + expect(loadingEl.querySelector('i').getAttribute('aria-label')).toBe('Loading projects'); + done(); + }); + }); + + it('should render frequent projects list header', done => { + Vue.nextTick(() => { + const sectionHeaderEl = vm.$el.querySelector('.section-header'); + + expect(sectionHeaderEl).toBeDefined(); + expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited'); + done(); + }); + }); + + it('should render frequent projects list', done => { + const expectedResult = getTopFrequentItems(mockFrequentProjects); + spyOn(window.localStorage, 'getItem').and.callFake(() => + JSON.stringify(mockFrequentProjects), + ); + + expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1); + + vm.fetchFrequentItems(); + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe( + expectedResult.length, + ); + done(); + }); + }); + + it('should render searched projects list', done => { + mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects); + + expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1); + + vm.$store.dispatch('setSearchQuery', 'gitlab'); + vm + .$nextTick() + .then(() => { + expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); + }) + .then(vm.$nextTick) + .then(vm.$nextTick) + .then(() => { + expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe( + mockSearchedProjects.length, + ); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js b/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js index c193258474e..201aca77b10 100644 --- a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js +++ b/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js @@ -1,23 +1,21 @@ import Vue from 'vue'; - -import projectsListItemComponent from '~/projects_dropdown/components/projects_list_item.vue'; - +import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { mockProject } from '../mock_data'; +import { mockProject } from '../mock_data'; // can also use 'mockGroup', but not useful to test here const createComponent = () => { - const Component = Vue.extend(projectsListItemComponent); + const Component = Vue.extend(frequentItemsListItemComponent); return mountComponent(Component, { - projectId: mockProject.id, - projectName: mockProject.name, + itemId: mockProject.id, + itemName: mockProject.name, namespace: mockProject.namespace, webUrl: mockProject.webUrl, avatarUrl: mockProject.avatarUrl, }); }; -describe('ProjectsListItemComponent', () => { +describe('FrequentItemsListItemComponent', () => { let vm; beforeEach(() => { @@ -32,22 +30,22 @@ describe('ProjectsListItemComponent', () => { describe('hasAvatar', () => { it('should return `true` or `false` if whether avatar is present or not', () => { vm.avatarUrl = 'path/to/avatar.png'; - expect(vm.hasAvatar).toBeTruthy(); + expect(vm.hasAvatar).toBe(true); vm.avatarUrl = null; - expect(vm.hasAvatar).toBeFalsy(); + expect(vm.hasAvatar).toBe(false); }); }); - describe('highlightedProjectName', () => { + describe('highlightedItemName', () => { it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => { vm.matcher = 'lab'; - expect(vm.highlightedProjectName).toContain('<b>Lab</b>'); + expect(vm.highlightedItemName).toContain('<b>Lab</b>'); }); it('should return project name as it is if `matcher` is not available', () => { vm.matcher = null; - expect(vm.highlightedProjectName).toBe(mockProject.name); + expect(vm.highlightedItemName).toBe(mockProject.name); }); }); @@ -66,12 +64,12 @@ describe('ProjectsListItemComponent', () => { describe('template', () => { it('should render component element', () => { - expect(vm.$el.classList.contains('projects-list-item-container')).toBeTruthy(); + expect(vm.$el.classList.contains('frequent-items-list-item-container')).toBeTruthy(); expect(vm.$el.querySelectorAll('a').length).toBe(1); - expect(vm.$el.querySelectorAll('.project-item-avatar-container').length).toBe(1); - expect(vm.$el.querySelectorAll('.project-item-metadata-container').length).toBe(1); - expect(vm.$el.querySelectorAll('.project-title').length).toBe(1); - expect(vm.$el.querySelectorAll('.project-namespace').length).toBe(1); + expect(vm.$el.querySelectorAll('.frequent-items-item-avatar-container').length).toBe(1); + expect(vm.$el.querySelectorAll('.frequent-items-item-metadata-container').length).toBe(1); + expect(vm.$el.querySelectorAll('.frequent-items-item-title').length).toBe(1); + expect(vm.$el.querySelectorAll('.frequent-items-item-namespace').length).toBe(1); }); }); }); diff --git a/spec/javascripts/frequent_items/components/frequent_items_list_spec.js b/spec/javascripts/frequent_items/components/frequent_items_list_spec.js new file mode 100644 index 00000000000..3003b7ee000 --- /dev/null +++ b/spec/javascripts/frequent_items/components/frequent_items_list_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import frequentItemsListComponent from '~/frequent_items/components/frequent_items_list.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { mockFrequentProjects } from '../mock_data'; + +const createComponent = (namespace = 'projects') => { + const Component = Vue.extend(frequentItemsListComponent); + + return mountComponent(Component, { + namespace, + items: mockFrequentProjects, + isFetchFailed: false, + hasSearchQuery: false, + matcher: 'lab', + }); +}; + +describe('FrequentItemsListComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('isListEmpty', () => { + it('should return `true` or `false` representing whether if `items` is empty or not with projects', () => { + vm.items = []; + expect(vm.isListEmpty).toBe(true); + + vm.items = mockFrequentProjects; + expect(vm.isListEmpty).toBe(false); + }); + }); + + describe('fetched item messages', () => { + it('should return appropriate empty list message based on value of `localStorageFailed` prop with projects', () => { + vm.isFetchFailed = true; + expect(vm.listEmptyMessage).toBe('This feature requires browser localStorage support'); + + vm.isFetchFailed = false; + expect(vm.listEmptyMessage).toBe('Projects you visit often will appear here'); + }); + }); + + describe('searched item messages', () => { + it('should return appropriate empty list message based on value of `searchFailed` prop with projects', () => { + vm.hasSearchQuery = true; + vm.isFetchFailed = true; + expect(vm.listEmptyMessage).toBe('Something went wrong on our end.'); + + vm.isFetchFailed = false; + expect(vm.listEmptyMessage).toBe('Sorry, no projects matched your search'); + }); + }); + }); + + describe('template', () => { + it('should render component element with list of projects', done => { + vm.items = mockFrequentProjects; + + Vue.nextTick(() => { + expect(vm.$el.classList.contains('frequent-items-list-container')).toBe(true); + expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1); + expect(vm.$el.querySelectorAll('li.frequent-items-list-item-container').length).toBe(5); + done(); + }); + }); + + it('should render component element with empty message', done => { + vm.items = []; + + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1); + expect(vm.$el.querySelectorAll('li.frequent-items-list-item-container').length).toBe(0); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/frequent_items/components/frequent_items_search_input_spec.js b/spec/javascripts/frequent_items/components/frequent_items_search_input_spec.js new file mode 100644 index 00000000000..6a11038e70a --- /dev/null +++ b/spec/javascripts/frequent_items/components/frequent_items_search_input_spec.js @@ -0,0 +1,77 @@ +import Vue from 'vue'; +import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue'; +import eventHub from '~/frequent_items/event_hub'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +const createComponent = (namespace = 'projects') => { + const Component = Vue.extend(searchComponent); + + return mountComponent(Component, { namespace }); +}; + +describe('FrequentItemsSearchInputComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('methods', () => { + describe('setFocus', () => { + it('should set focus to search input', () => { + spyOn(vm.$refs.search, 'focus'); + + vm.setFocus(); + expect(vm.$refs.search.focus).toHaveBeenCalled(); + }); + }); + }); + + describe('mounted', () => { + it('should listen `dropdownOpen` event', done => { + spyOn(eventHub, '$on'); + const vmX = createComponent(); + + Vue.nextTick(() => { + expect(eventHub.$on).toHaveBeenCalledWith( + `${vmX.namespace}-dropdownOpen`, + jasmine.any(Function), + ); + done(); + }); + }); + }); + + describe('beforeDestroy', () => { + it('should unbind event listeners on eventHub', done => { + const vmX = createComponent(); + spyOn(eventHub, '$off'); + + vmX.$mount(); + vmX.$destroy(); + + Vue.nextTick(() => { + expect(eventHub.$off).toHaveBeenCalledWith( + `${vmX.namespace}-dropdownOpen`, + jasmine.any(Function), + ); + done(); + }); + }); + }); + + describe('template', () => { + it('should render component element', () => { + const inputEl = vm.$el.querySelector('input.form-control'); + + expect(vm.$el.classList.contains('search-input-container')).toBeTruthy(); + expect(inputEl).not.toBe(null); + expect(inputEl.getAttribute('placeholder')).toBe('Search your projects'); + expect(vm.$el.querySelector('.search-icon')).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/frequent_items/mock_data.js b/spec/javascripts/frequent_items/mock_data.js new file mode 100644 index 00000000000..cf3602f42d6 --- /dev/null +++ b/spec/javascripts/frequent_items/mock_data.js @@ -0,0 +1,168 @@ +export const currentSession = { + groups: { + username: 'root', + storageKey: 'root/frequent-groups', + apiVersion: 'v4', + group: { + id: 1, + name: 'dummy-group', + full_name: 'dummy-parent-group', + webUrl: `${gl.TEST_HOST}/dummy-group`, + avatarUrl: null, + lastAccessedOn: Date.now(), + }, + }, + projects: { + username: 'root', + storageKey: 'root/frequent-projects', + apiVersion: 'v4', + project: { + id: 1, + name: 'dummy-project', + namespace: 'SampleGroup / Dummy-Project', + webUrl: `${gl.TEST_HOST}/samplegroup/dummy-project`, + avatarUrl: null, + lastAccessedOn: Date.now(), + }, + }, +}; + +export const mockNamespace = 'projects'; + +export const mockStorageKey = 'test-user/frequent-projects'; + +export const mockGroup = { + id: 1, + name: 'Sub451', + namespace: 'Commit451 / Sub451', + webUrl: `${gl.TEST_HOST}/Commit451/Sub451`, + avatarUrl: null, +}; + +export const mockRawGroup = { + id: 1, + name: 'Sub451', + full_name: 'Commit451 / Sub451', + web_url: `${gl.TEST_HOST}/Commit451/Sub451`, + avatar_url: null, +}; + +export const mockFrequentGroups = [ + { + id: 3, + name: 'Subgroup451', + full_name: 'Commit451 / Subgroup451', + webUrl: '/Commit451/Subgroup451', + avatarUrl: null, + frequency: 7, + lastAccessedOn: 1497979281815, + }, + { + id: 1, + name: 'Commit451', + full_name: 'Commit451', + webUrl: '/Commit451', + avatarUrl: null, + frequency: 3, + lastAccessedOn: 1497979281815, + }, +]; + +export const mockSearchedGroups = [mockRawGroup]; +export const mockProcessedSearchedGroups = [mockGroup]; + +export const mockProject = { + id: 1, + name: 'GitLab Community Edition', + namespace: 'gitlab-org / gitlab-ce', + webUrl: `${gl.TEST_HOST}/gitlab-org/gitlab-ce`, + avatarUrl: null, +}; + +export const mockRawProject = { + id: 1, + name: 'GitLab Community Edition', + name_with_namespace: 'gitlab-org / gitlab-ce', + web_url: `${gl.TEST_HOST}/gitlab-org/gitlab-ce`, + avatar_url: null, +}; + +export const mockFrequentProjects = [ + { + id: 1, + name: 'GitLab Community Edition', + namespace: 'gitlab-org / gitlab-ce', + webUrl: `${gl.TEST_HOST}/gitlab-org/gitlab-ce`, + avatarUrl: null, + frequency: 1, + lastAccessedOn: Date.now(), + }, + { + id: 2, + name: 'GitLab CI', + namespace: 'gitlab-org / gitlab-ci', + webUrl: `${gl.TEST_HOST}/gitlab-org/gitlab-ci`, + avatarUrl: null, + frequency: 9, + lastAccessedOn: Date.now(), + }, + { + id: 3, + name: 'Typeahead.Js', + namespace: 'twitter / typeahead-js', + webUrl: `${gl.TEST_HOST}/twitter/typeahead-js`, + avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png', + frequency: 2, + lastAccessedOn: Date.now(), + }, + { + id: 4, + name: 'Intel', + namespace: 'platform / hardware / bsp / intel', + webUrl: `${gl.TEST_HOST}/platform/hardware/bsp/intel`, + avatarUrl: null, + frequency: 3, + lastAccessedOn: Date.now(), + }, + { + id: 5, + name: 'v4.4', + namespace: 'platform / hardware / bsp / kernel / common / v4.4', + webUrl: `${gl.TEST_HOST}/platform/hardware/bsp/kernel/common/v4.4`, + avatarUrl: null, + frequency: 8, + lastAccessedOn: Date.now(), + }, +]; + +export const mockSearchedProjects = [mockRawProject]; +export const mockProcessedSearchedProjects = [mockProject]; + +export const unsortedFrequentItems = [ + { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, + { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, + { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, + { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, + { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, + { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, + { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, + { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, + { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, +]; + +/** + * This const has a specific order which tests authenticity + * of `getTopFrequentItems` method so + * DO NOT change order of items in this const. + */ +export const sortedFrequentItems = [ + { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, + { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, + { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, + { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, + { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, + { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, + { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, + { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, + { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, +]; diff --git a/spec/javascripts/frequent_items/store/actions_spec.js b/spec/javascripts/frequent_items/store/actions_spec.js new file mode 100644 index 00000000000..0cdd033d38f --- /dev/null +++ b/spec/javascripts/frequent_items/store/actions_spec.js @@ -0,0 +1,225 @@ +import testAction from 'spec/helpers/vuex_action_helper'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import AccessorUtilities from '~/lib/utils/accessor'; +import * as actions from '~/frequent_items/store/actions'; +import * as types from '~/frequent_items/store/mutation_types'; +import state from '~/frequent_items/store/state'; +import { + mockNamespace, + mockStorageKey, + mockFrequentProjects, + mockSearchedProjects, +} from '../mock_data'; + +describe('Frequent Items Dropdown Store Actions', () => { + let mockedState; + let mock; + + beforeEach(() => { + mockedState = state(); + mock = new MockAdapter(axios); + + mockedState.namespace = mockNamespace; + mockedState.storageKey = mockStorageKey; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('setNamespace', () => { + it('should set namespace', done => { + testAction( + actions.setNamespace, + mockNamespace, + mockedState, + [{ type: types.SET_NAMESPACE, payload: mockNamespace }], + [], + done, + ); + }); + }); + + describe('setStorageKey', () => { + it('should set storage key', done => { + testAction( + actions.setStorageKey, + mockStorageKey, + mockedState, + [{ type: types.SET_STORAGE_KEY, payload: mockStorageKey }], + [], + done, + ); + }); + }); + + describe('requestFrequentItems', () => { + it('should request frequent items', done => { + testAction( + actions.requestFrequentItems, + null, + mockedState, + [{ type: types.REQUEST_FREQUENT_ITEMS }], + [], + done, + ); + }); + }); + + describe('receiveFrequentItemsSuccess', () => { + it('should set frequent items', done => { + testAction( + actions.receiveFrequentItemsSuccess, + mockFrequentProjects, + mockedState, + [{ type: types.RECEIVE_FREQUENT_ITEMS_SUCCESS, payload: mockFrequentProjects }], + [], + done, + ); + }); + }); + + describe('receiveFrequentItemsError', () => { + it('should set frequent items error state', done => { + testAction( + actions.receiveFrequentItemsError, + null, + mockedState, + [{ type: types.RECEIVE_FREQUENT_ITEMS_ERROR }], + [], + done, + ); + }); + }); + + describe('fetchFrequentItems', () => { + it('should dispatch `receiveFrequentItemsSuccess`', done => { + mockedState.namespace = mockNamespace; + mockedState.storageKey = mockStorageKey; + + testAction( + actions.fetchFrequentItems, + null, + mockedState, + [], + [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsSuccess', payload: [] }], + done, + ); + }); + + it('should dispatch `receiveFrequentItemsError`', done => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(false); + mockedState.namespace = mockNamespace; + mockedState.storageKey = mockStorageKey; + + testAction( + actions.fetchFrequentItems, + null, + mockedState, + [], + [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsError' }], + done, + ); + }); + }); + + describe('requestSearchedItems', () => { + it('should request searched items', done => { + testAction( + actions.requestSearchedItems, + null, + mockedState, + [{ type: types.REQUEST_SEARCHED_ITEMS }], + [], + done, + ); + }); + }); + + describe('receiveSearchedItemsSuccess', () => { + it('should set searched items', done => { + testAction( + actions.receiveSearchedItemsSuccess, + mockSearchedProjects, + mockedState, + [{ type: types.RECEIVE_SEARCHED_ITEMS_SUCCESS, payload: mockSearchedProjects }], + [], + done, + ); + }); + }); + + describe('receiveSearchedItemsError', () => { + it('should set searched items error state', done => { + testAction( + actions.receiveSearchedItemsError, + null, + mockedState, + [{ type: types.RECEIVE_SEARCHED_ITEMS_ERROR }], + [], + done, + ); + }); + }); + + describe('fetchSearchedItems', () => { + beforeEach(() => { + gon.api_version = 'v4'; + }); + + it('should dispatch `receiveSearchedItemsSuccess`', done => { + mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects); + + testAction( + actions.fetchSearchedItems, + null, + mockedState, + [], + [ + { type: 'requestSearchedItems' }, + { type: 'receiveSearchedItemsSuccess', payload: mockSearchedProjects }, + ], + done, + ); + }); + + it('should dispatch `receiveSearchedItemsError`', done => { + gon.api_version = 'v4'; + mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(500); + + testAction( + actions.fetchSearchedItems, + null, + mockedState, + [], + [{ type: 'requestSearchedItems' }, { type: 'receiveSearchedItemsError' }], + done, + ); + }); + }); + + describe('setSearchQuery', () => { + it('should commit query and dispatch `fetchSearchedItems` when query is present', done => { + testAction( + actions.setSearchQuery, + { query: 'test' }, + mockedState, + [{ type: types.SET_SEARCH_QUERY }], + [{ type: 'fetchSearchedItems', payload: { query: 'test' } }], + done, + ); + }); + + it('should commit query and dispatch `fetchFrequentItems` when query is empty', done => { + testAction( + actions.setSearchQuery, + null, + mockedState, + [{ type: types.SET_SEARCH_QUERY }], + [{ type: 'fetchFrequentItems' }], + done, + ); + }); + }); +}); diff --git a/spec/javascripts/frequent_items/store/getters_spec.js b/spec/javascripts/frequent_items/store/getters_spec.js new file mode 100644 index 00000000000..1cd12eb6832 --- /dev/null +++ b/spec/javascripts/frequent_items/store/getters_spec.js @@ -0,0 +1,24 @@ +import state from '~/frequent_items/store/state'; +import * as getters from '~/frequent_items/store/getters'; + +describe('Frequent Items Dropdown Store Getters', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('hasSearchQuery', () => { + it('should return `true` when search query is present', () => { + mockedState.searchQuery = 'test'; + + expect(getters.hasSearchQuery(mockedState)).toBe(true); + }); + + it('should return `false` when search query is empty', () => { + mockedState.searchQuery = ''; + + expect(getters.hasSearchQuery(mockedState)).toBe(false); + }); + }); +}); diff --git a/spec/javascripts/frequent_items/store/mutations_spec.js b/spec/javascripts/frequent_items/store/mutations_spec.js new file mode 100644 index 00000000000..d36964b2600 --- /dev/null +++ b/spec/javascripts/frequent_items/store/mutations_spec.js @@ -0,0 +1,117 @@ +import state from '~/frequent_items/store/state'; +import mutations from '~/frequent_items/store/mutations'; +import * as types from '~/frequent_items/store/mutation_types'; +import { + mockNamespace, + mockStorageKey, + mockFrequentProjects, + mockSearchedProjects, + mockProcessedSearchedProjects, + mockSearchedGroups, + mockProcessedSearchedGroups, +} from '../mock_data'; + +describe('Frequent Items dropdown mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state(); + }); + + describe('SET_NAMESPACE', () => { + it('should set namespace', () => { + mutations[types.SET_NAMESPACE](stateCopy, mockNamespace); + + expect(stateCopy.namespace).toEqual(mockNamespace); + }); + }); + + describe('SET_STORAGE_KEY', () => { + it('should set storage key', () => { + mutations[types.SET_STORAGE_KEY](stateCopy, mockStorageKey); + + expect(stateCopy.storageKey).toEqual(mockStorageKey); + }); + }); + + describe('SET_SEARCH_QUERY', () => { + it('should set search query', () => { + const searchQuery = 'gitlab-ce'; + + mutations[types.SET_SEARCH_QUERY](stateCopy, searchQuery); + + expect(stateCopy.searchQuery).toEqual(searchQuery); + }); + }); + + describe('REQUEST_FREQUENT_ITEMS', () => { + it('should set view states when requesting frequent items', () => { + mutations[types.REQUEST_FREQUENT_ITEMS](stateCopy); + + expect(stateCopy.isLoadingItems).toEqual(true); + expect(stateCopy.hasSearchQuery).toEqual(false); + }); + }); + + describe('RECEIVE_FREQUENT_ITEMS_SUCCESS', () => { + it('should set view states when receiving frequent items', () => { + mutations[types.RECEIVE_FREQUENT_ITEMS_SUCCESS](stateCopy, mockFrequentProjects); + + expect(stateCopy.items).toEqual(mockFrequentProjects); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(false); + expect(stateCopy.isFetchFailed).toEqual(false); + }); + }); + + describe('RECEIVE_FREQUENT_ITEMS_ERROR', () => { + it('should set items and view states when error occurs retrieving frequent items', () => { + mutations[types.RECEIVE_FREQUENT_ITEMS_ERROR](stateCopy); + + expect(stateCopy.items).toEqual([]); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(false); + expect(stateCopy.isFetchFailed).toEqual(true); + }); + }); + + describe('REQUEST_SEARCHED_ITEMS', () => { + it('should set view states when requesting searched items', () => { + mutations[types.REQUEST_SEARCHED_ITEMS](stateCopy); + + expect(stateCopy.isLoadingItems).toEqual(true); + expect(stateCopy.hasSearchQuery).toEqual(true); + }); + }); + + describe('RECEIVE_SEARCHED_ITEMS_SUCCESS', () => { + it('should set items and view states when receiving searched items', () => { + mutations[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](stateCopy, mockSearchedProjects); + + expect(stateCopy.items).toEqual(mockProcessedSearchedProjects); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(true); + expect(stateCopy.isFetchFailed).toEqual(false); + }); + + it('should also handle the different `full_name` key for namespace in groups payload', () => { + mutations[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](stateCopy, mockSearchedGroups); + + expect(stateCopy.items).toEqual(mockProcessedSearchedGroups); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(true); + expect(stateCopy.isFetchFailed).toEqual(false); + }); + }); + + describe('RECEIVE_SEARCHED_ITEMS_ERROR', () => { + it('should set view states when error occurs retrieving searched items', () => { + mutations[types.RECEIVE_SEARCHED_ITEMS_ERROR](stateCopy); + + expect(stateCopy.items).toEqual([]); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(true); + expect(stateCopy.isFetchFailed).toEqual(true); + }); + }); +}); diff --git a/spec/javascripts/frequent_items/utils_spec.js b/spec/javascripts/frequent_items/utils_spec.js new file mode 100644 index 00000000000..cd27d79b29a --- /dev/null +++ b/spec/javascripts/frequent_items/utils_spec.js @@ -0,0 +1,89 @@ +import bp from '~/breakpoints'; +import { isMobile, getTopFrequentItems, updateExistingFrequentItem } from '~/frequent_items/utils'; +import { HOUR_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants'; +import { mockProject, unsortedFrequentItems, sortedFrequentItems } from './mock_data'; + +describe('Frequent Items utils spec', () => { + describe('isMobile', () => { + it('returns true when the screen is small ', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); + + expect(isMobile()).toBe(true); + }); + + it('returns true when the screen is extra-small ', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('xs'); + + expect(isMobile()).toBe(true); + }); + + it('returns false when the screen is larger than small ', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('md'); + + expect(isMobile()).toBe(false); + }); + }); + + describe('getTopFrequentItems', () => { + it('returns empty array if no items provided', () => { + const result = getTopFrequentItems(); + + expect(result.length).toBe(0); + }); + + it('returns correct amount of items for mobile', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); + const result = getTopFrequentItems(unsortedFrequentItems); + + expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_MOBILE); + }); + + it('returns correct amount of items for desktop', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('lg'); + const result = getTopFrequentItems(unsortedFrequentItems); + + expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_DESKTOP); + }); + + it('sorts frequent items in order of frequency and lastAccessedOn', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('lg'); + const result = getTopFrequentItems(unsortedFrequentItems); + const expectedResult = sortedFrequentItems.slice(0, FREQUENT_ITEMS.LIST_COUNT_DESKTOP); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('updateExistingFrequentItem', () => { + let mockedProject; + + beforeEach(() => { + mockedProject = { + ...mockProject, + frequency: 1, + lastAccessedOn: 1497979281815, + }; + }); + + it('updates item if accessed over an hour ago', () => { + const newTimestamp = Date.now() + HOUR_IN_MS + 1; + const newItem = { + ...mockedProject, + lastAccessedOn: newTimestamp, + }; + const result = updateExistingFrequentItem(mockedProject, newItem); + + expect(result.frequency).toBe(mockedProject.frequency + 1); + }); + + it('does not update item if accessed within the hour', () => { + const newItem = { + ...mockedProject, + lastAccessedOn: mockedProject.lastAccessedOn + HOUR_IN_MS, + }; + const result = updateExistingFrequentItem(mockedProject, newItem); + + expect(result.frequency).toBe(mockedProject.frequency); + }); + }); +}); diff --git a/spec/javascripts/ide/components/merge_requests/info_spec.js b/spec/javascripts/ide/components/merge_requests/info_spec.js new file mode 100644 index 00000000000..98a29e5128b --- /dev/null +++ b/spec/javascripts/ide/components/merge_requests/info_spec.js @@ -0,0 +1,51 @@ +import Vue from 'vue'; +import '~/behaviors/markdown/render_gfm'; +import { createStore } from '~/ide/stores'; +import Info from '~/ide/components/merge_requests/info.vue'; +import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; + +describe('IDE merge request details', () => { + let Component; + let vm; + + beforeAll(() => { + Component = Vue.extend(Info); + }); + + beforeEach(() => { + const store = createStore(); + store.state.currentProjectId = 'gitlab-ce'; + store.state.currentMergeRequestId = 1; + store.state.projects['gitlab-ce'] = { + mergeRequests: { + 1: { + iid: 1, + title: 'Testing', + title_html: '<span class="title-html">Testing</span>', + description: 'Description', + description_html: '<p class="description-html">Description HTML</p>', + }, + }, + }; + + vm = createComponentWithStore(Component, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders merge request IID', () => { + expect(vm.$el.querySelector('.detail-page-header').textContent).toContain('!1'); + }); + + it('renders title as HTML', () => { + expect(vm.$el.querySelector('.title-html')).not.toBe(null); + expect(vm.$el.querySelector('.title').textContent).toContain('Testing'); + }); + + it('renders description as HTML', () => { + expect(vm.$el.querySelector('.description-html')).not.toBe(null); + expect(vm.$el.querySelector('.description').textContent).toContain('Description HTML'); + }); +}); diff --git a/spec/javascripts/ide/components/panes/right_spec.js b/spec/javascripts/ide/components/panes/right_spec.js new file mode 100644 index 00000000000..99879fb0930 --- /dev/null +++ b/spec/javascripts/ide/components/panes/right_spec.js @@ -0,0 +1,72 @@ +import Vue from 'vue'; +import '~/behaviors/markdown/render_gfm'; +import { createStore } from '~/ide/stores'; +import RightPane from '~/ide/components/panes/right.vue'; +import { rightSidebarViews } from '~/ide/constants'; +import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; + +describe('IDE right pane', () => { + let Component; + let vm; + + beforeAll(() => { + Component = Vue.extend(RightPane); + }); + + beforeEach(() => { + const store = createStore(); + + vm = createComponentWithStore(Component, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('active', () => { + it('renders merge request button as active', done => { + vm.$store.state.rightPane = rightSidebarViews.mergeRequestInfo; + vm.$store.state.currentMergeRequestId = '123'; + vm.$store.state.currentProjectId = 'gitlab-ce'; + vm.$store.state.currentMergeRequestId = 1; + vm.$store.state.projects['gitlab-ce'] = { + mergeRequests: { + 1: { + iid: 1, + title: 'Testing', + title_html: '<span class="title-html">Testing</span>', + description: 'Description', + description_html: '<p class="description-html">Description HTML</p>', + }, + }, + }; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ide-sidebar-link.active')).not.toBe(null); + expect( + vm.$el.querySelector('.ide-sidebar-link.active').getAttribute('data-original-title'), + ).toBe('Merge Request'); + + done(); + }); + }); + }); + + describe('click', () => { + beforeEach(() => { + spyOn(vm, 'setRightPane'); + }); + + it('sets view to merge request', done => { + vm.$store.state.currentMergeRequestId = '123'; + + vm.$nextTick(() => { + vm.$el.querySelector('.ide-sidebar-link').click(); + + expect(vm.setRightPane).toHaveBeenCalledWith(rightSidebarViews.mergeRequestInfo); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/merge_request_spec.js b/spec/javascripts/ide/stores/actions/merge_request_spec.js index c99ccc70c6a..90c28c769f7 100644 --- a/spec/javascripts/ide/stores/actions/merge_request_spec.js +++ b/spec/javascripts/ide/stores/actions/merge_request_spec.js @@ -39,7 +39,9 @@ describe('IDE store merge request actions', () => { store .dispatch('getMergeRequestData', { projectId: 'abcproject', mergeRequestId: 1 }) .then(() => { - expect(service.getProjectMergeRequestData).toHaveBeenCalledWith('abcproject', 1); + expect(service.getProjectMergeRequestData).toHaveBeenCalledWith('abcproject', 1, { + render_html: true, + }); done(); }) diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js index 1b040c924aa..be2a8ba67fe 100644 --- a/spec/javascripts/notes/mock_data.js +++ b/spec/javascripts/notes/mock_data.js @@ -165,6 +165,7 @@ export const note = { report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1', path: '/gitlab-org/gitlab-ce/notes/546', + cached_markdown_version: 11, }; export const discussionMock = { diff --git a/spec/javascripts/profile/add_ssh_key_validation_spec.js b/spec/javascripts/profile/add_ssh_key_validation_spec.js new file mode 100644 index 00000000000..c71a2885acc --- /dev/null +++ b/spec/javascripts/profile/add_ssh_key_validation_spec.js @@ -0,0 +1,69 @@ +import AddSshKeyValidation from '../../../app/assets/javascripts/profile/add_ssh_key_validation'; + +describe('AddSshKeyValidation', () => { + describe('submit', () => { + it('returns true if isValid is true', () => { + const addSshKeyValidation = new AddSshKeyValidation({}); + spyOn(AddSshKeyValidation, 'isPublicKey').and.returnValue(true); + + expect(addSshKeyValidation.submit()).toBeTruthy(); + }); + + it('calls preventDefault and toggleWarning if isValid is false', () => { + const addSshKeyValidation = new AddSshKeyValidation({}); + const event = jasmine.createSpyObj('event', ['preventDefault']); + spyOn(AddSshKeyValidation, 'isPublicKey').and.returnValue(false); + spyOn(addSshKeyValidation, 'toggleWarning'); + + addSshKeyValidation.submit(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(addSshKeyValidation.toggleWarning).toHaveBeenCalledWith(true); + }); + }); + + describe('toggleWarning', () => { + it('shows warningElement and hides originalSubmitElement if isVisible is true', () => { + const warningElement = document.createElement('div'); + const originalSubmitElement = document.createElement('div'); + warningElement.classList.add('hide'); + + const addSshKeyValidation = new AddSshKeyValidation( + {}, + warningElement, + originalSubmitElement, + ); + addSshKeyValidation.toggleWarning(true); + + expect(warningElement.classList.contains('hide')).toBeFalsy(); + expect(originalSubmitElement.classList.contains('hide')).toBeTruthy(); + }); + + it('hides warningElement and shows originalSubmitElement if isVisible is false', () => { + const warningElement = document.createElement('div'); + const originalSubmitElement = document.createElement('div'); + originalSubmitElement.classList.add('hide'); + + const addSshKeyValidation = new AddSshKeyValidation( + {}, + warningElement, + originalSubmitElement, + ); + addSshKeyValidation.toggleWarning(false); + + expect(warningElement.classList.contains('hide')).toBeTruthy(); + expect(originalSubmitElement.classList.contains('hide')).toBeFalsy(); + }); + }); + + describe('isPublicKey', () => { + it('returns false if probably invalid public ssh key', () => { + expect(AddSshKeyValidation.isPublicKey('nope')).toBeFalsy(); + }); + + it('returns true if probably valid public ssh key', () => { + expect(AddSshKeyValidation.isPublicKey('ssh-')).toBeTruthy(); + expect(AddSshKeyValidation.isPublicKey('ecdsa-sha2-')).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/components/app_spec.js b/spec/javascripts/projects_dropdown/components/app_spec.js deleted file mode 100644 index 38b31c3d727..00000000000 --- a/spec/javascripts/projects_dropdown/components/app_spec.js +++ /dev/null @@ -1,349 +0,0 @@ -import Vue from 'vue'; - -import bp from '~/breakpoints'; -import appComponent from '~/projects_dropdown/components/app.vue'; -import eventHub from '~/projects_dropdown/event_hub'; -import ProjectsStore from '~/projects_dropdown/store/projects_store'; -import ProjectsService from '~/projects_dropdown/service/projects_service'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { currentSession, mockProject, mockRawProject } from '../mock_data'; - -const createComponent = () => { - gon.api_version = currentSession.apiVersion; - const Component = Vue.extend(appComponent); - const store = new ProjectsStore(); - const service = new ProjectsService(currentSession.username); - - return mountComponent(Component, { - store, - service, - currentUserName: currentSession.username, - currentProject: currentSession.project, - }); -}; - -const returnServicePromise = (data, failed) => - new Promise((resolve, reject) => { - if (failed) { - reject(data); - } else { - resolve({ - json() { - return data; - }, - }); - } - }); - -describe('AppComponent', () => { - describe('computed', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('frequentProjects', () => { - it('should return list of frequently accessed projects from store', () => { - expect(vm.frequentProjects).toBeDefined(); - expect(vm.frequentProjects.length).toBe(0); - - vm.store.setFrequentProjects([mockProject]); - expect(vm.frequentProjects).toBeDefined(); - expect(vm.frequentProjects.length).toBe(1); - }); - }); - - describe('searchProjects', () => { - it('should return list of frequently accessed projects from store', () => { - expect(vm.searchProjects).toBeDefined(); - expect(vm.searchProjects.length).toBe(0); - - vm.store.setSearchedProjects([mockRawProject]); - expect(vm.searchProjects).toBeDefined(); - expect(vm.searchProjects.length).toBe(1); - }); - }); - }); - - describe('methods', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('toggleFrequentProjectsList', () => { - it('should toggle props which control visibility of Frequent Projects list from state passed', () => { - vm.toggleFrequentProjectsList(true); - expect(vm.isLoadingProjects).toBeFalsy(); - expect(vm.isSearchListVisible).toBeFalsy(); - expect(vm.isFrequentsListVisible).toBeTruthy(); - - vm.toggleFrequentProjectsList(false); - expect(vm.isLoadingProjects).toBeTruthy(); - expect(vm.isSearchListVisible).toBeTruthy(); - expect(vm.isFrequentsListVisible).toBeFalsy(); - }); - }); - - describe('toggleSearchProjectsList', () => { - it('should toggle props which control visibility of Searched Projects list from state passed', () => { - vm.toggleSearchProjectsList(true); - expect(vm.isLoadingProjects).toBeFalsy(); - expect(vm.isFrequentsListVisible).toBeFalsy(); - expect(vm.isSearchListVisible).toBeTruthy(); - - vm.toggleSearchProjectsList(false); - expect(vm.isLoadingProjects).toBeTruthy(); - expect(vm.isFrequentsListVisible).toBeTruthy(); - expect(vm.isSearchListVisible).toBeFalsy(); - }); - }); - - describe('toggleLoader', () => { - it('should toggle props which control visibility of list loading animation from state passed', () => { - vm.toggleLoader(true); - expect(vm.isFrequentsListVisible).toBeFalsy(); - expect(vm.isSearchListVisible).toBeFalsy(); - expect(vm.isLoadingProjects).toBeTruthy(); - - vm.toggleLoader(false); - expect(vm.isFrequentsListVisible).toBeTruthy(); - expect(vm.isSearchListVisible).toBeTruthy(); - expect(vm.isLoadingProjects).toBeFalsy(); - }); - }); - - describe('fetchFrequentProjects', () => { - it('should set props for loading animation to `true` while frequent projects list is being loaded', () => { - spyOn(vm, 'toggleLoader'); - - vm.fetchFrequentProjects(); - expect(vm.isLocalStorageFailed).toBeFalsy(); - expect(vm.toggleLoader).toHaveBeenCalledWith(true); - }); - - it('should set props for loading animation to `false` and props for frequent projects list to `true` once data is loaded', () => { - const mockData = [mockProject]; - - spyOn(vm.service, 'getFrequentProjects').and.returnValue(mockData); - spyOn(vm.store, 'setFrequentProjects'); - spyOn(vm, 'toggleFrequentProjectsList'); - - vm.fetchFrequentProjects(); - expect(vm.service.getFrequentProjects).toHaveBeenCalled(); - expect(vm.store.setFrequentProjects).toHaveBeenCalledWith(mockData); - expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true); - }); - - it('should set props for failure message to `true` when method fails to fetch frequent projects list', () => { - spyOn(vm.service, 'getFrequentProjects').and.returnValue(null); - spyOn(vm.store, 'setFrequentProjects'); - spyOn(vm, 'toggleFrequentProjectsList'); - - expect(vm.isLocalStorageFailed).toBeFalsy(); - - vm.fetchFrequentProjects(); - expect(vm.service.getFrequentProjects).toHaveBeenCalled(); - expect(vm.store.setFrequentProjects).toHaveBeenCalledWith([]); - expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true); - expect(vm.isLocalStorageFailed).toBeTruthy(); - }); - - it('should set props for search results list to `true` if search query was already made previously', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('md'); - spyOn(vm.service, 'getFrequentProjects'); - spyOn(vm, 'toggleSearchProjectsList'); - - vm.searchQuery = 'test'; - vm.fetchFrequentProjects(); - expect(vm.service.getFrequentProjects).not.toHaveBeenCalled(); - expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true); - }); - - it('should set props for frequent projects list to `true` if search query was already made but screen size is less than 768px', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); - spyOn(vm, 'toggleSearchProjectsList'); - spyOn(vm.service, 'getFrequentProjects'); - - vm.searchQuery = 'test'; - vm.fetchFrequentProjects(); - expect(vm.service.getFrequentProjects).toHaveBeenCalled(); - expect(vm.toggleSearchProjectsList).not.toHaveBeenCalled(); - }); - }); - - describe('fetchSearchedProjects', () => { - const searchQuery = 'test'; - - it('should perform search with provided search query', done => { - const mockData = [mockRawProject]; - spyOn(vm, 'toggleLoader'); - spyOn(vm, 'toggleSearchProjectsList'); - spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise(mockData)); - spyOn(vm.store, 'setSearchedProjects'); - - vm.fetchSearchedProjects(searchQuery); - setTimeout(() => { - expect(vm.searchQuery).toBe(searchQuery); - expect(vm.toggleLoader).toHaveBeenCalledWith(true); - expect(vm.service.getSearchedProjects).toHaveBeenCalledWith(searchQuery); - expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true); - expect(vm.store.setSearchedProjects).toHaveBeenCalledWith(mockData); - done(); - }, 0); - }); - - it('should update props for showing search failure', done => { - spyOn(vm, 'toggleSearchProjectsList'); - spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise({}, true)); - - vm.fetchSearchedProjects(searchQuery); - setTimeout(() => { - expect(vm.searchQuery).toBe(searchQuery); - expect(vm.service.getSearchedProjects).toHaveBeenCalledWith(searchQuery); - expect(vm.isSearchFailed).toBeTruthy(); - expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true); - done(); - }, 0); - }); - }); - - describe('logCurrentProjectAccess', () => { - it('should log current project access via service', done => { - spyOn(vm.service, 'logProjectAccess'); - - vm.currentProject = mockProject; - vm.logCurrentProjectAccess(); - - setTimeout(() => { - expect(vm.service.logProjectAccess).toHaveBeenCalledWith(mockProject); - done(); - }, 1); - }); - }); - - describe('handleSearchClear', () => { - it('should show frequent projects list when search input is cleared', () => { - spyOn(vm.store, 'clearSearchedProjects'); - spyOn(vm, 'toggleFrequentProjectsList'); - - vm.handleSearchClear(); - - expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true); - expect(vm.store.clearSearchedProjects).toHaveBeenCalled(); - expect(vm.searchQuery).toBe(''); - }); - }); - - describe('handleSearchFailure', () => { - it('should show failure message within dropdown', () => { - spyOn(vm, 'toggleSearchProjectsList'); - - vm.handleSearchFailure(); - expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true); - expect(vm.isSearchFailed).toBeTruthy(); - }); - }); - }); - - describe('created', () => { - it('should bind event listeners on eventHub', done => { - spyOn(eventHub, '$on'); - - createComponent().$mount(); - - Vue.nextTick(() => { - expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('searchProjects', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('searchCleared', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('searchFailed', jasmine.any(Function)); - done(); - }); - }); - }); - - describe('beforeDestroy', () => { - it('should unbind event listeners on eventHub', done => { - const vm = createComponent(); - spyOn(eventHub, '$off'); - - vm.$mount(); - vm.$destroy(); - - Vue.nextTick(() => { - expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('searchProjects', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('searchCleared', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('searchFailed', jasmine.any(Function)); - done(); - }); - }); - }); - - describe('template', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('should render search input', () => { - expect(vm.$el.querySelector('.search-input-container')).toBeDefined(); - }); - - it('should render loading animation', done => { - vm.toggleLoader(true); - Vue.nextTick(() => { - const loadingEl = vm.$el.querySelector('.loading-animation'); - - expect(loadingEl).toBeDefined(); - expect(loadingEl.classList.contains('prepend-top-20')).toBeTruthy(); - expect(loadingEl.querySelector('i').getAttribute('aria-label')).toBe('Loading projects'); - done(); - }); - }); - - it('should render frequent projects list header', done => { - vm.toggleFrequentProjectsList(true); - Vue.nextTick(() => { - const sectionHeaderEl = vm.$el.querySelector('.section-header'); - - expect(sectionHeaderEl).toBeDefined(); - expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited'); - done(); - }); - }); - - it('should render frequent projects list', done => { - vm.toggleFrequentProjectsList(true); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.projects-list-frequent-container')).toBeDefined(); - done(); - }); - }); - - it('should render searched projects list', done => { - vm.toggleSearchProjectsList(true); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.section-header')).toBe(null); - expect(vm.$el.querySelector('.projects-list-search-container')).toBeDefined(); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js deleted file mode 100644 index 2bafb4e81ca..00000000000 --- a/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js +++ /dev/null @@ -1,72 +0,0 @@ -import Vue from 'vue'; - -import projectsListFrequentComponent from '~/projects_dropdown/components/projects_list_frequent.vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { mockFrequents } from '../mock_data'; - -const createComponent = () => { - const Component = Vue.extend(projectsListFrequentComponent); - - return mountComponent(Component, { - projects: mockFrequents, - localStorageFailed: false, - }); -}; - -describe('ProjectsListFrequentComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('computed', () => { - describe('isListEmpty', () => { - it('should return `true` or `false` representing whether if `projects` is empty of not', () => { - vm.projects = []; - expect(vm.isListEmpty).toBeTruthy(); - - vm.projects = mockFrequents; - expect(vm.isListEmpty).toBeFalsy(); - }); - }); - - describe('listEmptyMessage', () => { - it('should return appropriate empty list message based on value of `localStorageFailed` prop', () => { - vm.localStorageFailed = true; - expect(vm.listEmptyMessage).toBe('This feature requires browser localStorage support'); - - vm.localStorageFailed = false; - expect(vm.listEmptyMessage).toBe('Projects you visit often will appear here'); - }); - }); - }); - - describe('template', () => { - it('should render component element with list of projects', (done) => { - vm.projects = mockFrequents; - - Vue.nextTick(() => { - expect(vm.$el.classList.contains('projects-list-frequent-container')).toBeTruthy(); - expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1); - expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(5); - done(); - }); - }); - - it('should render component element with empty message', (done) => { - vm.projects = []; - - Vue.nextTick(() => { - expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1); - expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js deleted file mode 100644 index c4b86d77034..00000000000 --- a/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import Vue from 'vue'; - -import projectsListSearchComponent from '~/projects_dropdown/components/projects_list_search.vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { mockProject } from '../mock_data'; - -const createComponent = () => { - const Component = Vue.extend(projectsListSearchComponent); - - return mountComponent(Component, { - projects: [mockProject], - matcher: 'lab', - searchFailed: false, - }); -}; - -describe('ProjectsListSearchComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('computed', () => { - describe('isListEmpty', () => { - it('should return `true` or `false` representing whether if `projects` is empty of not', () => { - vm.projects = []; - expect(vm.isListEmpty).toBeTruthy(); - - vm.projects = [mockProject]; - expect(vm.isListEmpty).toBeFalsy(); - }); - }); - - describe('listEmptyMessage', () => { - it('should return appropriate empty list message based on value of `searchFailed` prop', () => { - vm.searchFailed = true; - expect(vm.listEmptyMessage).toBe('Something went wrong on our end.'); - - vm.searchFailed = false; - expect(vm.listEmptyMessage).toBe('Sorry, no projects matched your search'); - }); - }); - }); - - describe('template', () => { - it('should render component element with list of projects', (done) => { - vm.projects = [mockProject]; - - Vue.nextTick(() => { - expect(vm.$el.classList.contains('projects-list-search-container')).toBeTruthy(); - expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1); - expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(1); - done(); - }); - }); - - it('should render component element with empty message', (done) => { - vm.projects = []; - - Vue.nextTick(() => { - expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1); - expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0); - done(); - }); - }); - - it('should render component element with failure message', (done) => { - vm.searchFailed = true; - vm.projects = []; - - Vue.nextTick(() => { - expect(vm.$el.querySelectorAll('li.section-empty.section-failure').length).toBe(1); - expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/projects_dropdown/components/search_spec.js b/spec/javascripts/projects_dropdown/components/search_spec.js deleted file mode 100644 index 427f5024e3a..00000000000 --- a/spec/javascripts/projects_dropdown/components/search_spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import Vue from 'vue'; - -import searchComponent from '~/projects_dropdown/components/search.vue'; -import eventHub from '~/projects_dropdown/event_hub'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; - -const createComponent = () => { - const Component = Vue.extend(searchComponent); - - return mountComponent(Component); -}; - -describe('SearchComponent', () => { - describe('methods', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('setFocus', () => { - it('should set focus to search input', () => { - spyOn(vm.$refs.search, 'focus'); - - vm.setFocus(); - expect(vm.$refs.search.focus).toHaveBeenCalled(); - }); - }); - - describe('emitSearchEvents', () => { - it('should emit `searchProjects` event via eventHub when `searchQuery` present', () => { - const searchQuery = 'test'; - spyOn(eventHub, '$emit'); - vm.searchQuery = searchQuery; - vm.emitSearchEvents(); - expect(eventHub.$emit).toHaveBeenCalledWith('searchProjects', searchQuery); - }); - - it('should emit `searchCleared` event via eventHub when `searchQuery` is cleared', () => { - spyOn(eventHub, '$emit'); - vm.searchQuery = ''; - vm.emitSearchEvents(); - expect(eventHub.$emit).toHaveBeenCalledWith('searchCleared'); - }); - }); - }); - - describe('mounted', () => { - it('should listen `dropdownOpen` event', (done) => { - spyOn(eventHub, '$on'); - createComponent(); - - Vue.nextTick(() => { - expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); - done(); - }); - }); - }); - - describe('beforeDestroy', () => { - it('should unbind event listeners on eventHub', (done) => { - const vm = createComponent(); - spyOn(eventHub, '$off'); - - vm.$mount(); - vm.$destroy(); - - Vue.nextTick(() => { - expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); - done(); - }); - }); - }); - - describe('template', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('should render component element', () => { - const inputEl = vm.$el.querySelector('input.form-control'); - - expect(vm.$el.classList.contains('search-input-container')).toBeTruthy(); - expect(inputEl).not.toBe(null); - expect(inputEl.getAttribute('placeholder')).toBe('Search your projects'); - expect(vm.$el.querySelector('.search-icon')).toBeDefined(); - }); - }); -}); diff --git a/spec/javascripts/projects_dropdown/mock_data.js b/spec/javascripts/projects_dropdown/mock_data.js deleted file mode 100644 index d6a79fb8ac1..00000000000 --- a/spec/javascripts/projects_dropdown/mock_data.js +++ /dev/null @@ -1,96 +0,0 @@ -export const currentSession = { - username: 'root', - storageKey: 'root/frequent-projects', - apiVersion: 'v4', - project: { - id: 1, - name: 'dummy-project', - namespace: 'SamepleGroup / Dummy-Project', - webUrl: 'http://127.0.0.1/samplegroup/dummy-project', - avatarUrl: null, - lastAccessedOn: Date.now(), - }, -}; - -export const mockProject = { - id: 1, - name: 'GitLab Community Edition', - namespace: 'gitlab-org / gitlab-ce', - webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce', - avatarUrl: null, -}; - -export const mockRawProject = { - id: 1, - name: 'GitLab Community Edition', - name_with_namespace: 'gitlab-org / gitlab-ce', - web_url: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce', - avatar_url: null, -}; - -export const mockFrequents = [ - { - id: 1, - name: 'GitLab Community Edition', - namespace: 'gitlab-org / gitlab-ce', - webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce', - avatarUrl: null, - }, - { - id: 2, - name: 'GitLab CI', - namespace: 'gitlab-org / gitlab-ci', - webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ci', - avatarUrl: null, - }, - { - id: 3, - name: 'Typeahead.Js', - namespace: 'twitter / typeahead-js', - webUrl: 'http://127.0.0.1:3000/twitter/typeahead-js', - avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png', - }, - { - id: 4, - name: 'Intel', - namespace: 'platform / hardware / bsp / intel', - webUrl: 'http://127.0.0.1:3000/platform/hardware/bsp/intel', - avatarUrl: null, - }, - { - id: 5, - name: 'v4.4', - namespace: 'platform / hardware / bsp / kernel / common / v4.4', - webUrl: 'http://localhost:3000/platform/hardware/bsp/kernel/common/v4.4', - avatarUrl: null, - }, -]; - -export const unsortedFrequents = [ - { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, - { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, - { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, - { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, - { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, - { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, - { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, - { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, - { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, -]; - -/** - * This const has a specific order which tests authenticity - * of `ProjectsService.getTopFrequentProjects` method so - * DO NOT change order of items in this const. - */ -export const sortedFrequents = [ - { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, - { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, - { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, - { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, - { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, - { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, - { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, - { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, - { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, -]; diff --git a/spec/javascripts/projects_dropdown/service/projects_service_spec.js b/spec/javascripts/projects_dropdown/service/projects_service_spec.js deleted file mode 100644 index cfd1bb7d24f..00000000000 --- a/spec/javascripts/projects_dropdown/service/projects_service_spec.js +++ /dev/null @@ -1,179 +0,0 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -import bp from '~/breakpoints'; -import ProjectsService from '~/projects_dropdown/service/projects_service'; -import { FREQUENT_PROJECTS } from '~/projects_dropdown/constants'; -import { currentSession, unsortedFrequents, sortedFrequents } from '../mock_data'; - -Vue.use(VueResource); - -FREQUENT_PROJECTS.MAX_COUNT = 3; - -describe('ProjectsService', () => { - let service; - - beforeEach(() => { - gon.api_version = currentSession.apiVersion; - gon.current_user_id = 1; - service = new ProjectsService(currentSession.username); - }); - - describe('contructor', () => { - it('should initialize default properties of class', () => { - expect(service.isLocalStorageAvailable).toBeTruthy(); - expect(service.currentUserName).toBe(currentSession.username); - expect(service.storageKey).toBe(currentSession.storageKey); - expect(service.projectsPath).toBeDefined(); - }); - }); - - describe('getSearchedProjects', () => { - it('should return promise from VueResource HTTP GET', () => { - spyOn(service.projectsPath, 'get').and.stub(); - - const searchQuery = 'lab'; - const queryParams = { - simple: true, - per_page: 20, - membership: true, - order_by: 'last_activity_at', - search: searchQuery, - }; - - service.getSearchedProjects(searchQuery); - expect(service.projectsPath.get).toHaveBeenCalledWith(queryParams); - }); - }); - - describe('logProjectAccess', () => { - let storage; - - beforeEach(() => { - storage = {}; - - spyOn(window.localStorage, 'setItem').and.callFake((storageKey, value) => { - storage[storageKey] = value; - }); - - spyOn(window.localStorage, 'getItem').and.callFake((storageKey) => { - if (storage[storageKey]) { - return storage[storageKey]; - } - - return null; - }); - }); - - it('should create a project store if it does not exist and adds a project', () => { - service.logProjectAccess(currentSession.project); - - const projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects.length).toBe(1); - expect(projects[0].frequency).toBe(1); - expect(projects[0].lastAccessedOn).toBeDefined(); - }); - - it('should prevent inserting same report multiple times into store', () => { - service.logProjectAccess(currentSession.project); - service.logProjectAccess(currentSession.project); - - const projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects.length).toBe(1); - }); - - it('should increase frequency of report if it was logged multiple times over the course of an hour', () => { - let projects; - spyOn(Math, 'abs').and.returnValue(3600001); // this will lead to `diff` > 1; - service.logProjectAccess(currentSession.project); - - projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects[0].frequency).toBe(1); - - service.logProjectAccess(currentSession.project); - projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects[0].frequency).toBe(2); - expect(projects[0].lastAccessedOn).not.toBe(currentSession.project.lastAccessedOn); - }); - - it('should always update project metadata', () => { - let projects; - const oldProject = { - ...currentSession.project, - }; - - const newProject = { - ...currentSession.project, - name: 'New Name', - avatarUrl: 'new/avatar.png', - namespace: 'New / Namespace', - webUrl: 'http://localhost/new/web/url', - }; - - service.logProjectAccess(oldProject); - projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects[0].name).toBe(oldProject.name); - expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl); - expect(projects[0].namespace).toBe(oldProject.namespace); - expect(projects[0].webUrl).toBe(oldProject.webUrl); - - service.logProjectAccess(newProject); - projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects[0].name).toBe(newProject.name); - expect(projects[0].avatarUrl).toBe(newProject.avatarUrl); - expect(projects[0].namespace).toBe(newProject.namespace); - expect(projects[0].webUrl).toBe(newProject.webUrl); - }); - - it('should not add more than 20 projects in store', () => { - for (let i = 1; i <= 5; i += 1) { - const project = Object.assign(currentSession.project, { id: i }); - service.logProjectAccess(project); - } - - const projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects.length).toBe(3); - }); - }); - - describe('getTopFrequentProjects', () => { - let storage = {}; - - beforeEach(() => { - storage[currentSession.storageKey] = JSON.stringify(unsortedFrequents); - - spyOn(window.localStorage, 'getItem').and.callFake((storageKey) => { - if (storage[storageKey]) { - return storage[storageKey]; - } - - return null; - }); - }); - - it('should return top 5 frequently accessed projects for desktop screens', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('md'); - const frequentProjects = service.getTopFrequentProjects(); - - expect(frequentProjects.length).toBe(5); - frequentProjects.forEach((project, index) => { - expect(project.id).toBe(sortedFrequents[index].id); - }); - }); - - it('should return top 3 frequently accessed projects for mobile screens', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); - const frequentProjects = service.getTopFrequentProjects(); - - expect(frequentProjects.length).toBe(3); - frequentProjects.forEach((project, index) => { - expect(project.id).toBe(sortedFrequents[index].id); - }); - }); - - it('should return empty array if there are no projects available in store', () => { - storage = {}; - expect(service.getTopFrequentProjects().length).toBe(0); - }); - }); -}); diff --git a/spec/javascripts/projects_dropdown/store/projects_store_spec.js b/spec/javascripts/projects_dropdown/store/projects_store_spec.js deleted file mode 100644 index e57399d37cd..00000000000 --- a/spec/javascripts/projects_dropdown/store/projects_store_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import ProjectsStore from '~/projects_dropdown/store/projects_store'; -import { mockProject, mockRawProject } from '../mock_data'; - -describe('ProjectsStore', () => { - let store; - - beforeEach(() => { - store = new ProjectsStore(); - }); - - describe('setFrequentProjects', () => { - it('should set frequent projects list to state', () => { - store.setFrequentProjects([mockProject]); - - expect(store.getFrequentProjects().length).toBe(1); - expect(store.getFrequentProjects()[0].id).toBe(mockProject.id); - }); - }); - - describe('setSearchedProjects', () => { - it('should set searched projects list to state', () => { - store.setSearchedProjects([mockRawProject]); - - const processedProjects = store.getSearchedProjects(); - expect(processedProjects.length).toBe(1); - expect(processedProjects[0].id).toBe(mockRawProject.id); - expect(processedProjects[0].namespace).toBe(mockRawProject.name_with_namespace); - expect(processedProjects[0].webUrl).toBe(mockRawProject.web_url); - expect(processedProjects[0].avatarUrl).toBe(mockRawProject.avatar_url); - }); - }); - - describe('clearSearchedProjects', () => { - it('should clear searched projects list from state', () => { - store.setSearchedProjects([mockRawProject]); - expect(store.getSearchedProjects().length).toBe(1); - store.clearSearchedProjects(); - expect(store.getSearchedProjects().length).toBe(0); - }); - }); -}); diff --git a/spec/javascripts/vue_mr_widget/components/deployment_spec.js b/spec/javascripts/vue_mr_widget/components/deployment_spec.js index c82ba61a5b1..50c2b0e2bd0 100644 --- a/spec/javascripts/vue_mr_widget/components/deployment_spec.js +++ b/spec/javascripts/vue_mr_widget/components/deployment_spec.js @@ -153,7 +153,7 @@ describe('Deployment component', () => { it('renders external URL', () => { expect(el.querySelector('.js-deploy-url').getAttribute('href')).toEqual(deploymentMockData.external_url); - expect(el.querySelector('.js-deploy-url').innerText).toContain(deploymentMockData.external_url_formatted); + expect(el.querySelector('.js-deploy-url').innerText).toContain('View app'); }); it('renders stop button', () => { diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js index 3d36e46d863..61b7bd2c226 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js @@ -145,7 +145,7 @@ describe('MRWidgetHeader', () => { it('renders web ide button', () => { const button = vm.$el.querySelector('.js-web-ide'); - expect(button.textContent.trim()).toEqual('Web IDE'); + expect(button.textContent.trim()).toEqual('Open in Web IDE'); expect(button.getAttribute('href')).toEqual('/-/ide/projectabc'); }); @@ -154,7 +154,7 @@ describe('MRWidgetHeader', () => { const button = vm.$el.querySelector('.js-web-ide'); - expect(button.textContent.trim()).toEqual('Web IDE'); + expect(button.textContent.trim()).toEqual('Open in Web IDE'); expect(button.getAttribute('href')).toEqual('/-/ide/projectabc'); }); @@ -253,8 +253,8 @@ describe('MRWidgetHeader', () => { }); it('renders diverged commits info', () => { - expect(vm.$el.querySelector('.diverged-commits-count').textContent.trim()).toEqual( - '(12 commits behind)', + expect(vm.$el.querySelector('.diverged-commits-count').textContent).toMatch( + /(mr-widget-refactor[\s\S]+?is 12 commits behind[\s\S]+?master)/, ); }); }); diff --git a/spec/lib/banzai/filter/markdown_filter_spec.rb b/spec/lib/banzai/filter/markdown_filter_spec.rb index ab14d77d552..a515d07b072 100644 --- a/spec/lib/banzai/filter/markdown_filter_spec.rb +++ b/spec/lib/banzai/filter/markdown_filter_spec.rb @@ -3,17 +3,61 @@ require 'spec_helper' describe Banzai::Filter::MarkdownFilter do include FilterSpecHelper - context 'code block' do - it 'adds language to lang attribute when specified' do - result = filter("```html\nsome code\n```") + describe 'markdown engine from context' do + it 'defaults to CommonMark' do + expect_any_instance_of(Banzai::Filter::MarkdownEngines::CommonMark).to receive(:render).and_return('test') - expect(result).to start_with("<pre><code lang=\"html\">") + filter('test') end - it 'does not add language to lang attribute when not specified' do - result = filter("```\nsome code\n```") + it 'uses Redcarpet' do + expect_any_instance_of(Banzai::Filter::MarkdownEngines::Redcarpet).to receive(:render).and_return('test') - expect(result).to start_with("<pre><code>") + filter('test', { markdown_engine: :redcarpet }) + end + + it 'uses CommonMark' do + expect_any_instance_of(Banzai::Filter::MarkdownEngines::CommonMark).to receive(:render).and_return('test') + + filter('test', { markdown_engine: :common_mark }) + end + end + + describe 'code block' do + context 'using CommonMark' do + before do + stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :common_mark) + end + + it 'adds language to lang attribute when specified' do + result = filter("```html\nsome code\n```") + + expect(result).to start_with("<pre><code lang=\"html\">") + end + + it 'does not add language to lang attribute when not specified' do + result = filter("```\nsome code\n```") + + expect(result).to start_with("<pre><code>") + end + end + + context 'using Redcarpet' do + before do + stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :redcarpet) + end + + it 'adds language to lang attribute when specified' do + result = filter("```html\nsome code\n```") + + expect(result).to start_with("\n<pre><code lang=\"html\">") + end + + it 'does not add language to lang attribute when not specified' do + result = filter("```\nsome code\n```") + + expect(result).to start_with("\n<pre><code>") + end end end end diff --git a/spec/lib/gitlab/background_migration/fix_cross_project_label_links_spec.rb b/spec/lib/gitlab/background_migration/fix_cross_project_label_links_spec.rb new file mode 100644 index 00000000000..20af63bc6c8 --- /dev/null +++ b/spec/lib/gitlab/background_migration/fix_cross_project_label_links_spec.rb @@ -0,0 +1,109 @@ +require 'spec_helper' + +describe Gitlab::BackgroundMigration::FixCrossProjectLabelLinks, :migration, schema: 20180702120647 do + let(:namespaces_table) { table(:namespaces) } + let(:projects_table) { table(:projects) } + let(:issues_table) { table(:issues) } + let(:merge_requests_table) { table(:merge_requests) } + let(:labels_table) { table(:labels) } + let(:label_links_table) { table(:label_links) } + + let!(:group1) { namespaces_table.create(id: 10, type: 'Group', name: 'group1', path: 'group1') } + let!(:group2) { namespaces_table.create(id: 20, type: 'Group', name: 'group2', path: 'group2') } + + let!(:project1) { projects_table.create(id: 1, name: 'project1', path: 'group1/project1', namespace_id: 10) } + let!(:project2) { projects_table.create(id: 3, name: 'project2', path: 'group1/project2', namespace_id: 20) } + + let!(:label1) { labels_table.create(id: 1, title: 'bug', color: 'red', group_id: 10, type: 'GroupLabel') } + let!(:label2) { labels_table.create(id: 2, title: 'bug', color: 'red', group_id: 20, type: 'GroupLabel') } + + def create_merge_request(id, project_id) + merge_requests_table.create(id: id, + target_project_id: project_id, + target_branch: 'master', + source_project_id: project_id, + source_branch: 'mr name', + title: "mr name#{id}") + end + + def create_issue(id, project_id) + issues_table.create(id: id, title: "issue#{id}", project_id: project_id) + end + + def create_resource(target_type, id, project_id) + target_type == 'Issue' ? create_issue(id, project_id) : create_merge_request(id, project_id) + end + + shared_examples_for 'resource with cross-project labels' do + it 'updates only cross-project label links which exist in the local project or group' do + create_resource(target_type, 1, 1) + create_resource(target_type, 2, 3) + labels_table.create(id: 3, title: 'bug', color: 'red', project_id: 3, type: 'ProjectLabel') + link = label_links_table.create(label_id: 2, target_type: target_type, target_id: 1) + link2 = label_links_table.create(label_id: 3, target_type: target_type, target_id: 2) + + subject.perform(1, 100) + + expect(link.reload.label_id).to eq(1) + expect(link2.reload.label_id).to eq(3) + end + + it 'ignores cross-project label links if label color is different' do + labels_table.create(id: 3, title: 'bug', color: 'green', group_id: 20, type: 'GroupLabel') + create_resource(target_type, 1, 1) + link = label_links_table.create(label_id: 3, target_type: target_type, target_id: 1) + + subject.perform(1, 100) + + expect(link.reload.label_id).to eq(3) + end + + it 'ignores cross-project label links if label name is different' do + labels_table.create(id: 3, title: 'bug1', color: 'red', group_id: 20, type: 'GroupLabel') + create_resource(target_type, 1, 1) + link = label_links_table.create(label_id: 3, target_type: target_type, target_id: 1) + + subject.perform(1, 100) + + expect(link.reload.label_id).to eq(3) + end + + context 'with nested group' do + before do + namespaces_table.create(id: 11, type: 'Group', name: 'subgroup1', path: 'group1/subgroup1', parent_id: 10) + projects_table.create(id: 2, name: 'subproject1', path: 'group1/subgroup1/subproject1', namespace_id: 11) + create_resource(target_type, 1, 2) + end + + it 'ignores label links referencing ancestor group labels', :nested_groups do + labels_table.create(id: 4, title: 'bug', color: 'red', project_id: 2, type: 'ProjectLabel') + label_links_table.create(label_id: 4, target_type: target_type, target_id: 1) + link = label_links_table.create(label_id: 1, target_type: target_type, target_id: 1) + + subject.perform(1, 100) + + expect(link.reload.label_id).to eq(1) + end + + it 'checks also issues and MRs in subgroups', :nested_groups do + link = label_links_table.create(label_id: 2, target_type: target_type, target_id: 1) + + subject.perform(1, 100) + + expect(link.reload.label_id).to eq(1) + end + end + end + + context 'resource is Issue' do + it_behaves_like 'resource with cross-project labels' do + let(:target_type) { 'Issue' } + end + end + + context 'resource is Merge Request' do + it_behaves_like 'resource with cross-project labels' do + let(:target_type) { 'MergeRequest' } + end + end +end diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index 8bb246aa4bd..782e4e45a91 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -357,6 +357,35 @@ describe Gitlab::Database do end end + describe '.db_read_only?' do + context 'when using PostgreSQL' do + before do + allow(ActiveRecord::Base.connection).to receive(:execute).and_call_original + expect(described_class).to receive(:postgresql?).and_return(true) + end + + it 'detects a read only database' do + allow(ActiveRecord::Base.connection).to receive(:execute).with('SELECT pg_is_in_recovery()').and_return([{ "pg_is_in_recovery" => "t" }]) + + expect(described_class.db_read_only?).to be_truthy + end + + it 'detects a read write database' do + allow(ActiveRecord::Base.connection).to receive(:execute).with('SELECT pg_is_in_recovery()').and_return([{ "pg_is_in_recovery" => "f" }]) + + expect(described_class.db_read_only?).to be_falsey + end + end + + context 'when using MySQL' do + before do + expect(described_class).to receive(:postgresql?).and_return(false) + end + + it { expect(described_class.db_read_only?).to be_falsey } + end + end + describe '#sanitize_timestamp' do let(:max_timestamp) { Time.at((1 << 31) - 1) } diff --git a/spec/lib/gitlab/exclusive_lease_helpers_spec.rb b/spec/lib/gitlab/exclusive_lease_helpers_spec.rb new file mode 100644 index 00000000000..2e3656b52fb --- /dev/null +++ b/spec/lib/gitlab/exclusive_lease_helpers_spec.rb @@ -0,0 +1,76 @@ +require 'spec_helper' + +describe Gitlab::ExclusiveLeaseHelpers, :clean_gitlab_redis_shared_state do + include ::ExclusiveLeaseHelpers + + let(:class_instance) { (Class.new { include ::Gitlab::ExclusiveLeaseHelpers }).new } + let(:unique_key) { SecureRandom.hex(10) } + + describe '#in_lock' do + subject { class_instance.in_lock(unique_key, **options) { } } + + let(:options) { {} } + + context 'when the lease is not obtained yet' do + before do + stub_exclusive_lease(unique_key, 'uuid') + end + + it 'calls the given block' do + expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_control.once + end + + it 'calls the given block continuously' do + expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_control.once + expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_control.once + expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_control.once + end + + it 'cancels the exclusive lease after the block' do + expect_to_cancel_exclusive_lease(unique_key, 'uuid') + + subject + end + end + + context 'when the lease is obtained already' do + let!(:lease) { stub_exclusive_lease_taken(unique_key) } + + it 'retries to obtain a lease and raises an error' do + expect(lease).to receive(:try_obtain).exactly(11).times + + expect { subject }.to raise_error('Failed to obtain a lock') + end + + context 'when ttl is specified' do + let(:options) { { ttl: 10.minutes } } + + it 'receives the specified argument' do + expect(Gitlab::ExclusiveLease).to receive(:new).with(unique_key, { timeout: 10.minutes } ) + + expect { subject }.to raise_error('Failed to obtain a lock') + end + end + + context 'when retry count is specified' do + let(:options) { { retries: 3 } } + + it 'retries for the specified times' do + expect(lease).to receive(:try_obtain).exactly(4).times + + expect { subject }.to raise_error('Failed to obtain a lock') + end + end + + context 'when sleep second is specified' do + let(:options) { { retries: 0, sleep_sec: 0.05.seconds } } + + it 'receives the specified argument' do + expect(class_instance).to receive(:sleep).with(0.05.seconds).once + + expect { subject }.to raise_error('Failed to obtain a lock') + end + end + end + end +end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 5dd7af3a552..e6268a05d44 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -1856,6 +1856,54 @@ describe Gitlab::Git::Repository, seed_helper: true do end end + describe '#set_config' do + let(:repository) { Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') } + let(:rugged) { repository_rugged } + let(:entries) do + { + 'test.foo1' => 'bla bla', + 'test.foo2' => 1234, + 'test.foo3' => true + } + end + + it 'can set config settings' do + expect(repository.set_config(entries)).to be_nil + + expect(rugged.config['test.foo1']).to eq('bla bla') + expect(rugged.config['test.foo2']).to eq('1234') + expect(rugged.config['test.foo3']).to eq('true') + end + + after do + entries.keys.each { |k| rugged.config.delete(k) } + end + end + + describe '#delete_config' do + let(:repository) { Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') } + let(:rugged) { repository_rugged } + let(:entries) do + { + 'test.foo1' => 'bla bla', + 'test.foo2' => 1234, + 'test.foo3' => true + } + end + + it 'can delete config settings' do + entries.each do |key, value| + rugged.config[key] = value + end + + expect(repository.delete_config(*%w[does.not.exist test.foo1 test.foo2])).to be_nil + + config_keys = rugged.config.each_key.to_a + expect(config_keys).not_to include('test.foo1') + expect(config_keys).not_to include('test.foo2') + end + end + describe '#merge' do let(:repository) do Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') diff --git a/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_object_storage_spec.rb b/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_object_storage_spec.rb new file mode 100644 index 00000000000..5059d68e54b --- /dev/null +++ b/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_object_storage_spec.rb @@ -0,0 +1,105 @@ +require 'spec_helper' + +describe Gitlab::ImportExport::AfterExportStrategies::BaseAfterExportStrategy do + let!(:service) { described_class.new } + let!(:project) { create(:project, :with_object_export) } + let(:shared) { project.import_export_shared } + let!(:user) { create(:user) } + + describe '#execute' do + before do + allow(service).to receive(:strategy_execute) + stub_feature_flags(import_export_object_storage: true) + end + + it 'returns if project exported file is not found' do + allow(project).to receive(:export_project_object_exists?).and_return(false) + + expect(service).not_to receive(:strategy_execute) + + service.execute(user, project) + end + + it 'creates a lock file in the export dir' do + allow(service).to receive(:delete_after_export_lock) + + service.execute(user, project) + + expect(lock_path_exist?).to be_truthy + end + + context 'when the method succeeds' do + it 'removes the lock file' do + service.execute(user, project) + + expect(lock_path_exist?).to be_falsey + end + end + + context 'when the method fails' do + before do + allow(service).to receive(:strategy_execute).and_call_original + end + + context 'when validation fails' do + before do + allow(service).to receive(:invalid?).and_return(true) + end + + it 'does not create the lock file' do + expect(service).not_to receive(:create_or_update_after_export_lock) + + service.execute(user, project) + end + + it 'does not execute main logic' do + expect(service).not_to receive(:strategy_execute) + + service.execute(user, project) + end + + it 'logs validation errors in shared context' do + expect(service).to receive(:log_validation_errors) + + service.execute(user, project) + end + end + + context 'when an exception is raised' do + it 'removes the lock' do + expect { service.execute(user, project) }.to raise_error(NotImplementedError) + + expect(lock_path_exist?).to be_falsey + end + end + end + end + + describe '#log_validation_errors' do + it 'add the message to the shared context' do + errors = %w(test_message test_message2) + + allow(service).to receive(:invalid?).and_return(true) + allow(service.errors).to receive(:full_messages).and_return(errors) + + expect(shared).to receive(:add_error_message).twice.and_call_original + + service.execute(user, project) + + expect(shared.errors).to eq errors + end + end + + describe '#to_json' do + it 'adds the current strategy class to the serialized attributes' do + params = { param1: 1 } + result = params.merge(klass: described_class.to_s).to_json + + expect(described_class.new(params).to_json).to eq result + end + end + + def lock_path_exist? + File.exist?(described_class.lock_file_path(project)) + end +end diff --git a/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb b/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb index ed54d87de4a..566b7f46c87 100644 --- a/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb +++ b/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb @@ -9,6 +9,7 @@ describe Gitlab::ImportExport::AfterExportStrategies::BaseAfterExportStrategy do describe '#execute' do before do allow(service).to receive(:strategy_execute) + stub_feature_flags(import_export_object_storage: false) end it 'returns if project exported file is not found' do diff --git a/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb b/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb index 5fe57d9987b..7f2e0a4ee2c 100644 --- a/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb +++ b/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb @@ -24,13 +24,34 @@ describe Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy do end describe '#execute' do - it 'removes the exported project file after the upload' do - allow(strategy).to receive(:send_file) - allow(strategy).to receive(:handle_response_error) + context 'without object storage' do + before do + stub_feature_flags(import_export_object_storage: false) + end + + it 'removes the exported project file after the upload' do + allow(strategy).to receive(:send_file) + allow(strategy).to receive(:handle_response_error) + + expect(project).to receive(:remove_exported_project_file) + + strategy.execute(user, project) + end + end + + context 'with object storage' do + before do + stub_feature_flags(import_export_object_storage: true) + end - expect(project).to receive(:remove_exported_project_file) + it 'removes the exported project file after the upload' do + allow(strategy).to receive(:send_file) + allow(strategy).to receive(:handle_response_error) - strategy.execute(user, project) + expect(project).to receive(:remove_exported_project_file) + + strategy.execute(user, project) + end end end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 2ea66479c1b..084ce3066d6 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -293,6 +293,7 @@ project: - deploy_tokens - settings - ci_cd_settings +- import_export_upload award_emoji: - awardable - user diff --git a/spec/lib/gitlab/import_export/saver_spec.rb b/spec/lib/gitlab/import_export/saver_spec.rb new file mode 100644 index 00000000000..02f1a4b81aa --- /dev/null +++ b/spec/lib/gitlab/import_export/saver_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' +require 'fileutils' + +describe Gitlab::ImportExport::Saver do + let!(:project) { create(:project, :public, name: 'project') } + let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } + let(:shared) { project.import_export_shared } + subject { described_class.new(project: project, shared: shared) } + + before do + allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) + + FileUtils.mkdir_p(shared.export_path) + FileUtils.touch("#{shared.export_path}/tmp.bundle") + end + + after do + FileUtils.rm_rf(export_path) + end + + context 'local archive' do + it 'saves the repo to disk' do + stub_feature_flags(import_export_object_storage: false) + + subject.save + + expect(shared.errors).to be_empty + expect(Dir.empty?(shared.archive_path)).to be false + end + end + + context 'object storage' do + it 'saves the repo using object storage' do + stub_feature_flags(import_export_object_storage: true) + stub_uploads_object_storage(ImportExportUploader) + + subject.save + + expect(ImportExportUpload.find_by(project: project).export_file.url) + .to match(%r[\/uploads\/-\/system\/import_export_upload\/export_file.*]) + end + end +end diff --git a/spec/lib/gitlab/middleware/read_only_spec.rb b/spec/lib/gitlab/middleware/read_only_spec.rb index 5c398bc2063..8fbeaa065fa 100644 --- a/spec/lib/gitlab/middleware/read_only_spec.rb +++ b/spec/lib/gitlab/middleware/read_only_spec.rb @@ -4,28 +4,6 @@ describe Gitlab::Middleware::ReadOnly do include Rack::Test::Methods using RSpec::Parameterized::TableSyntax - RSpec::Matchers.define :be_a_redirect do - match do |response| - response.status == 301 - end - end - - RSpec::Matchers.define :disallow_request do - match do |middleware| - alert = middleware.env['rack.session'].to_hash - .dig('flash', 'flashes', 'alert') - - alert&.include?('You cannot perform write operations') - end - end - - RSpec::Matchers.define :disallow_request_in_json do - match do |response| - json_response = JSON.parse(response.body) - response.body.include?('You cannot perform write operations') && json_response.key?('message') - end - end - let(:rack_stack) do rack = Rack::Builder.new do use ActionDispatch::Session::CacheStore @@ -66,38 +44,38 @@ describe Gitlab::Middleware::ReadOnly do it 'expects PATCH requests to be disallowed' do response = request.patch('/test_request') - expect(response).to be_a_redirect + expect(response).to be_redirect expect(subject).to disallow_request end it 'expects PUT requests to be disallowed' do response = request.put('/test_request') - expect(response).to be_a_redirect + expect(response).to be_redirect expect(subject).to disallow_request end it 'expects POST requests to be disallowed' do response = request.post('/test_request') - expect(response).to be_a_redirect + expect(response).to be_redirect expect(subject).to disallow_request end it 'expects a internal POST request to be allowed after a disallowed request' do response = request.post('/test_request') - expect(response).to be_a_redirect + expect(response).to be_redirect response = request.post("/api/#{API::API.version}/internal") - expect(response).not_to be_a_redirect + expect(response).not_to be_redirect end it 'expects DELETE requests to be disallowed' do response = request.delete('/test_request') - expect(response).to be_a_redirect + expect(response).to be_redirect expect(subject).to disallow_request end @@ -105,7 +83,7 @@ describe Gitlab::Middleware::ReadOnly do expect(Rails.application.routes).to receive(:recognize_path).and_call_original response = request.post('/root/gitlab-ce/new/master/app/info/lfs/objects/batch') - expect(response).to be_a_redirect + expect(response).to be_redirect expect(subject).to disallow_request end @@ -120,19 +98,19 @@ describe Gitlab::Middleware::ReadOnly do expect(Rails.application.routes).not_to receive(:recognize_path) response = request.post("/api/#{API::API.version}/internal") - expect(response).not_to be_a_redirect + expect(response).not_to be_redirect expect(subject).not_to disallow_request end it 'expects requests to sidekiq admin to be allowed' do response = request.post('/admin/sidekiq') - expect(response).not_to be_a_redirect + expect(response).not_to be_redirect expect(subject).not_to disallow_request response = request.get('/admin/sidekiq') - expect(response).not_to be_a_redirect + expect(response).not_to be_redirect expect(subject).not_to disallow_request end @@ -150,7 +128,7 @@ describe Gitlab::Middleware::ReadOnly do expect(Rails.application.routes).to receive(:recognize_path).and_call_original response = request.post(path) - expect(response).not_to be_a_redirect + expect(response).not_to be_redirect expect(subject).not_to disallow_request end end diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb index 464897de306..774a638b430 100644 --- a/spec/models/ci/build_trace_chunk_spec.rb +++ b/spec/models/ci/build_trace_chunk_spec.rb @@ -14,6 +14,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do before do stub_feature_flags(ci_enable_live_trace: true) + stub_artifacts_object_storage end context 'FastDestroyAll' do @@ -37,6 +38,22 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do end end + describe '.all_stores' do + subject { described_class.all_stores } + + it 'returns a correctly ordered array' do + is_expected.to eq(%w[redis database fog]) + end + + it 'returns redis store as the the lowest precedence' do + expect(subject.first).to eq('redis') + end + + it 'returns fog store as the the highest precedence' do + expect(subject.last).to eq('fog') + end + end + describe '#data' do subject { build_trace_chunk.data } @@ -44,181 +61,269 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data_store) { :redis } before do - build_trace_chunk.send(:redis_set_data, 'Sample data in redis') + build_trace_chunk.send(:unsafe_set_data!, 'Sample data in redis') end it { is_expected.to eq('Sample data in redis') } end context 'when data_store is database' do - let(:data_store) { :db } - let(:raw_data) { 'Sample data in db' } + let(:data_store) { :database } + let(:raw_data) { 'Sample data in database' } - it { is_expected.to eq('Sample data in db') } + it { is_expected.to eq('Sample data in database') } end - end - - describe '#set_data' do - subject { build_trace_chunk.send(:set_data, value) } - let(:value) { 'Sample data' } + context 'when data_store is fog' do + let(:data_store) { :fog } - context 'when value bytesize is bigger than CHUNK_SIZE' do - let(:value) { 'a' * (described_class::CHUNK_SIZE + 1) } + before do + build_trace_chunk.send(:unsafe_set_data!, 'Sample data in fog') + end - it { expect { subject }.to raise_error('too much data') } + it { is_expected.to eq('Sample data in fog') } end + end - context 'when data_store is redis' do - let(:data_store) { :redis } + describe '#append' do + subject { build_trace_chunk.append(new_data, offset) } - it do - expect(build_trace_chunk.send(:redis_data)).to be_nil + let(:new_data) { 'Sample new data' } + let(:offset) { 0 } + let(:merged_data) { data + new_data.to_s } - subject + shared_examples_for 'Appending correctly' do + context 'when offset is negative' do + let(:offset) { -1 } + + it { expect { subject }.to raise_error('Offset is out of range') } + end - expect(build_trace_chunk.send(:redis_data)).to eq(value) + context 'when offset is bigger than data size' do + let(:offset) { data.bytesize + 1 } + + it { expect { subject }.to raise_error('Offset is out of range') } end - context 'when fullfilled chunk size' do - let(:value) { 'a' * described_class::CHUNK_SIZE } + context 'when new data overflows chunk size' do + let(:new_data) { 'a' * (described_class::CHUNK_SIZE + 1) } - it 'schedules stashing data' do - expect(Ci::BuildTraceChunkFlushWorker).to receive(:perform_async).once + it { expect { subject }.to raise_error('Chunk size overflow') } + end + + context 'when offset is EOF' do + let(:offset) { data.bytesize } + it 'appends' do subject + + expect(build_trace_chunk.data).to eq(merged_data) end - end - end - context 'when data_store is database' do - let(:data_store) { :db } + context 'when the other process is appending' do + let(:lease_key) { "trace_write:#{build_trace_chunk.build.id}:chunks:#{build_trace_chunk.chunk_index}" } - it 'sets data' do - expect(build_trace_chunk.raw_data).to be_nil + before do + stub_exclusive_lease_taken(lease_key) + end - subject + it 'raise an error' do + expect { subject }.to raise_error('Failed to obtain a lock') + end + end - expect(build_trace_chunk.raw_data).to eq(value) - expect(build_trace_chunk.persisted?).to be_truthy - end + context 'when new_data is nil' do + let(:new_data) { nil } - context 'when raw_data is not changed' do - it 'does not execute UPDATE' do - expect(build_trace_chunk.raw_data).to be_nil - build_trace_chunk.save! + it 'raises an error' do + expect { subject }.to raise_error('New data is missing') + end + end - # First set - expect(ActiveRecord::QueryRecorder.new { subject }.count).to be > 0 - expect(build_trace_chunk.raw_data).to eq(value) - expect(build_trace_chunk.persisted?).to be_truthy + context 'when new_data is empty' do + let(:new_data) { '' } - # Second set - build_trace_chunk.reload - expect(ActiveRecord::QueryRecorder.new { subject }.count).to be(0) + it 'does not append' do + subject + + expect(build_trace_chunk.data).to eq(data) + end + + it 'does not execute UPDATE' do + ActiveRecord::QueryRecorder.new { subject }.log.map do |query| + expect(query).not_to include('UPDATE') + end + end end end - context 'when fullfilled chunk size' do - it 'does not schedule stashing data' do - expect(Ci::BuildTraceChunkFlushWorker).not_to receive(:perform_async) + context 'when offset is middle of datasize' do + let(:offset) { data.bytesize / 2 } + it 'appends' do subject + + expect(build_trace_chunk.data).to eq(data.byteslice(0, offset) + new_data) end end end - end - describe '#truncate' do - subject { build_trace_chunk.truncate(offset) } + shared_examples_for 'Scheduling sidekiq worker to flush data to persist store' do + context 'when new data fullfilled chunk size' do + let(:new_data) { 'a' * described_class::CHUNK_SIZE } - shared_examples_for 'truncates' do - context 'when offset is negative' do - let(:offset) { -1 } + it 'schedules trace chunk flush worker' do + expect(Ci::BuildTraceChunkFlushWorker).to receive(:perform_async).once - it { expect { subject }.to raise_error('Offset is out of range') } - end + subject + end - context 'when offset is bigger than data size' do - let(:offset) { data.bytesize + 1 } + it 'migrates data to object storage' do + Sidekiq::Testing.inline! do + subject - it { expect { subject }.to raise_error('Offset is out of range') } + build_trace_chunk.reload + expect(build_trace_chunk.fog?).to be_truthy + expect(build_trace_chunk.data).to eq(new_data) + end + end end + end - context 'when offset is 10' do - let(:offset) { 10 } + shared_examples_for 'Scheduling no sidekiq worker' do + context 'when new data fullfilled chunk size' do + let(:new_data) { 'a' * described_class::CHUNK_SIZE } + + it 'does not schedule trace chunk flush worker' do + expect(Ci::BuildTraceChunkFlushWorker).not_to receive(:perform_async) - it 'truncates' do subject + end - expect(build_trace_chunk.data).to eq(data.byteslice(0, offset)) + it 'does not migrate data to object storage' do + Sidekiq::Testing.inline! do + data_store = build_trace_chunk.data_store + + subject + + build_trace_chunk.reload + expect(build_trace_chunk.data_store).to eq(data_store) + end end end end context 'when data_store is redis' do let(:data_store) { :redis } - let(:data) { 'Sample data in redis' } - before do - build_trace_chunk.send(:redis_set_data, data) + context 'when there are no data' do + let(:data) { '' } + + it 'has no data' do + expect(build_trace_chunk.data).to be_empty + end + + it_behaves_like 'Appending correctly' + it_behaves_like 'Scheduling sidekiq worker to flush data to persist store' end - it_behaves_like 'truncates' - end + context 'when there are some data' do + let(:data) { 'Sample data in redis' } - context 'when data_store is database' do - let(:data_store) { :db } - let(:raw_data) { 'Sample data in db' } - let(:data) { raw_data } + before do + build_trace_chunk.send(:unsafe_set_data!, data) + end - it_behaves_like 'truncates' + it 'has data' do + expect(build_trace_chunk.data).to eq(data) + end + + it_behaves_like 'Appending correctly' + it_behaves_like 'Scheduling sidekiq worker to flush data to persist store' + end end - end - describe '#append' do - subject { build_trace_chunk.append(new_data, offset) } + context 'when data_store is database' do + let(:data_store) { :database } - let(:new_data) { 'Sample new data' } - let(:offset) { 0 } - let(:total_data) { data + new_data } + context 'when there are no data' do + let(:data) { '' } - shared_examples_for 'appends' do - context 'when offset is negative' do - let(:offset) { -1 } + it 'has no data' do + expect(build_trace_chunk.data).to be_empty + end - it { expect { subject }.to raise_error('Offset is out of range') } + it_behaves_like 'Appending correctly' + it_behaves_like 'Scheduling no sidekiq worker' end - context 'when offset is bigger than data size' do - let(:offset) { data.bytesize + 1 } + context 'when there are some data' do + let(:raw_data) { 'Sample data in database' } + let(:data) { raw_data } - it { expect { subject }.to raise_error('Offset is out of range') } + it 'has data' do + expect(build_trace_chunk.data).to eq(data) + end + + it_behaves_like 'Appending correctly' + it_behaves_like 'Scheduling no sidekiq worker' end + end - context 'when offset is bigger than data size' do - let(:new_data) { 'a' * (described_class::CHUNK_SIZE + 1) } + context 'when data_store is fog' do + let(:data_store) { :fog } - it { expect { subject }.to raise_error('Chunk size overflow') } + context 'when there are no data' do + let(:data) { '' } + + it 'has no data' do + expect(build_trace_chunk.data).to be_empty + end + + it_behaves_like 'Appending correctly' + it_behaves_like 'Scheduling no sidekiq worker' end - context 'when offset is EOF' do - let(:offset) { data.bytesize } + context 'when there are some data' do + let(:data) { 'Sample data in fog' } - it 'appends' do - subject + before do + build_trace_chunk.send(:unsafe_set_data!, data) + end - expect(build_trace_chunk.data).to eq(total_data) + it 'has data' do + expect(build_trace_chunk.data).to eq(data) end + + it_behaves_like 'Appending correctly' + it_behaves_like 'Scheduling no sidekiq worker' + end + end + end + + describe '#truncate' do + subject { build_trace_chunk.truncate(offset) } + + shared_examples_for 'truncates' do + context 'when offset is negative' do + let(:offset) { -1 } + + it { expect { subject }.to raise_error('Offset is out of range') } + end + + context 'when offset is bigger than data size' do + let(:offset) { data.bytesize + 1 } + + it { expect { subject }.to raise_error('Offset is out of range') } end context 'when offset is 10' do let(:offset) { 10 } - it 'appends' do + it 'truncates' do subject - expect(build_trace_chunk.data).to eq(data.byteslice(0, offset) + new_data) + expect(build_trace_chunk.data).to eq(data.byteslice(0, offset)) end end end @@ -228,18 +333,29 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data) { 'Sample data in redis' } before do - build_trace_chunk.send(:redis_set_data, data) + build_trace_chunk.send(:unsafe_set_data!, data) end - it_behaves_like 'appends' + it_behaves_like 'truncates' end context 'when data_store is database' do - let(:data_store) { :db } - let(:raw_data) { 'Sample data in db' } + let(:data_store) { :database } + let(:raw_data) { 'Sample data in database' } let(:data) { raw_data } - it_behaves_like 'appends' + it_behaves_like 'truncates' + end + + context 'when data_store is fog' do + let(:data_store) { :fog } + let(:data) { 'Sample data in fog' } + + before do + build_trace_chunk.send(:unsafe_set_data!, data) + end + + it_behaves_like 'truncates' end end @@ -253,7 +369,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data) { 'Sample data in redis' } before do - build_trace_chunk.send(:redis_set_data, data) + build_trace_chunk.send(:unsafe_set_data!, data) end it { is_expected.to eq(data.bytesize) } @@ -265,10 +381,10 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do end context 'when data_store is database' do - let(:data_store) { :db } + let(:data_store) { :database } context 'when data exists' do - let(:raw_data) { 'Sample data in db' } + let(:raw_data) { 'Sample data in database' } let(:data) { raw_data } it { is_expected.to eq(data.bytesize) } @@ -278,10 +394,43 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do it { is_expected.to eq(0) } end end + + context 'when data_store is fog' do + let(:data_store) { :fog } + + context 'when data exists' do + let(:data) { 'Sample data in fog' } + let(:key) { "tmp/builds/#{build.id}/chunks/#{chunk_index}.log" } + + before do + build_trace_chunk.send(:unsafe_set_data!, data) + end + + it { is_expected.to eq(data.bytesize) } + end + + context 'when data does not exist' do + it { is_expected.to eq(0) } + end + end end - describe '#use_database!' do - subject { build_trace_chunk.use_database! } + describe '#persist_data!' do + subject { build_trace_chunk.persist_data! } + + shared_examples_for 'Atomic operation' do + context 'when the other process is persisting' do + let(:lease_key) { "trace_write:#{build_trace_chunk.build.id}:chunks:#{build_trace_chunk.chunk_index}" } + + before do + stub_exclusive_lease_taken(lease_key) + end + + it 'raise an error' do + expect { subject }.to raise_error('Failed to obtain a lock') + end + end + end context 'when data_store is redis' do let(:data_store) { :redis } @@ -290,46 +439,93 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do let(:data) { 'Sample data in redis' } before do - build_trace_chunk.send(:redis_set_data, data) + build_trace_chunk.send(:unsafe_set_data!, data) end - it 'stashes the data' do - expect(build_trace_chunk.data_store).to eq('redis') - expect(build_trace_chunk.send(:redis_data)).to eq(data) - expect(build_trace_chunk.raw_data).to be_nil + it 'persists the data' do + expect(build_trace_chunk.redis?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to eq(data) + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil + expect { Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk) }.to raise_error(Excon::Error::NotFound) subject - expect(build_trace_chunk.data_store).to eq('db') - expect(build_trace_chunk.send(:redis_data)).to be_nil - expect(build_trace_chunk.raw_data).to eq(data) + expect(build_trace_chunk.fog?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data) end + + it_behaves_like 'Atomic operation' end context 'when data does not exist' do - it 'does not call UPDATE' do - expect(ActiveRecord::QueryRecorder.new { subject }.count).to eq(0) + it 'does not persist' do + expect { subject }.to raise_error('Can not persist empty data') end end end context 'when data_store is database' do - let(:data_store) { :db } + let(:data_store) { :database } - it 'does not call UPDATE' do - expect(ActiveRecord::QueryRecorder.new { subject }.count).to eq(0) + context 'when data exists' do + let(:data) { 'Sample data in database' } + + before do + build_trace_chunk.send(:unsafe_set_data!, data) + end + + it 'persists the data' do + expect(build_trace_chunk.database?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to eq(data) + expect { Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk) }.to raise_error(Excon::Error::NotFound) + + subject + + expect(build_trace_chunk.fog?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data) + end + + it_behaves_like 'Atomic operation' end - end - end - describe 'ExclusiveLock' do - before do - stub_exclusive_lease_taken - stub_const('Ci::BuildTraceChunk::WRITE_LOCK_RETRY', 1) + context 'when data does not exist' do + it 'does not persist' do + expect { subject }.to raise_error('Can not persist empty data') + end + end end - it 'raise an error' do - expect { build_trace_chunk.append('ABC', 0) }.to raise_error('Failed to obtain write lock') + context 'when data_store is fog' do + let(:data_store) { :fog } + + context 'when data exists' do + let(:data) { 'Sample data in fog' } + + before do + build_trace_chunk.send(:unsafe_set_data!, data) + end + + it 'does not change data store' do + expect(build_trace_chunk.fog?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data) + + subject + + expect(build_trace_chunk.fog?).to be_truthy + expect(Ci::BuildTraceChunks::Redis.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Database.new.data(build_trace_chunk)).to be_nil + expect(Ci::BuildTraceChunks::Fog.new.data(build_trace_chunk)).to eq(data) + end + + it_behaves_like 'Atomic operation' + end end end diff --git a/spec/models/ci/build_trace_chunks/database_spec.rb b/spec/models/ci/build_trace_chunks/database_spec.rb new file mode 100644 index 00000000000..d8fc9d57e95 --- /dev/null +++ b/spec/models/ci/build_trace_chunks/database_spec.rb @@ -0,0 +1,105 @@ +require 'spec_helper' + +describe Ci::BuildTraceChunks::Database do + let(:data_store) { described_class.new } + + describe '#available?' do + subject { data_store.available? } + + it { is_expected.to be_truthy } + end + + describe '#data' do + subject { data_store.data(model) } + + context 'when data exists' do + let(:model) { create(:ci_build_trace_chunk, :database_with_data, initial_data: 'sample data in database') } + + it 'returns the data' do + is_expected.to eq('sample data in database') + end + end + + context 'when data does not exist' do + let(:model) { create(:ci_build_trace_chunk, :database_without_data) } + + it 'returns nil' do + is_expected.to be_nil + end + end + end + + describe '#set_data' do + subject { data_store.set_data(model, data) } + + let(:data) { 'abc123' } + + context 'when data exists' do + let(:model) { create(:ci_build_trace_chunk, :database_with_data, initial_data: 'sample data in database') } + + it 'overwrites data' do + expect(data_store.data(model)).to eq('sample data in database') + + subject + + expect(data_store.data(model)).to eq('abc123') + end + end + + context 'when data does not exist' do + let(:model) { create(:ci_build_trace_chunk, :database_without_data) } + + it 'sets new data' do + expect(data_store.data(model)).to be_nil + + subject + + expect(data_store.data(model)).to eq('abc123') + end + end + end + + describe '#delete_data' do + subject { data_store.delete_data(model) } + + context 'when data exists' do + let(:model) { create(:ci_build_trace_chunk, :database_with_data, initial_data: 'sample data in database') } + + it 'deletes data' do + expect(data_store.data(model)).to eq('sample data in database') + + subject + + expect(data_store.data(model)).to be_nil + end + end + + context 'when data does not exist' do + let(:model) { create(:ci_build_trace_chunk, :database_without_data) } + + it 'does nothing' do + expect(data_store.data(model)).to be_nil + + subject + + expect(data_store.data(model)).to be_nil + end + end + end + + describe '#keys' do + subject { data_store.keys(relation) } + + let(:build) { create(:ci_build) } + let(:relation) { build.trace_chunks } + + before do + create(:ci_build_trace_chunk, :database_with_data, chunk_index: 0, build: build) + create(:ci_build_trace_chunk, :database_with_data, chunk_index: 1, build: build) + end + + it 'returns empty array' do + is_expected.to eq([]) + end + end +end diff --git a/spec/models/ci/build_trace_chunks/fog_spec.rb b/spec/models/ci/build_trace_chunks/fog_spec.rb new file mode 100644 index 00000000000..8f49190af13 --- /dev/null +++ b/spec/models/ci/build_trace_chunks/fog_spec.rb @@ -0,0 +1,146 @@ +require 'spec_helper' + +describe Ci::BuildTraceChunks::Fog do + let(:data_store) { described_class.new } + + before do + stub_artifacts_object_storage + end + + describe '#available?' do + subject { data_store.available? } + + context 'when object storage is enabled' do + it { is_expected.to be_truthy } + end + + context 'when object storage is disabled' do + before do + stub_artifacts_object_storage(enabled: false) + end + + it { is_expected.to be_falsy } + end + end + + describe '#data' do + subject { data_store.data(model) } + + context 'when data exists' do + let(:model) { create(:ci_build_trace_chunk, :fog_with_data, initial_data: 'sample data in fog') } + + it 'returns the data' do + is_expected.to eq('sample data in fog') + end + end + + context 'when data does not exist' do + let(:model) { create(:ci_build_trace_chunk, :fog_without_data) } + + it 'returns nil' do + expect { data_store.data(model) }.to raise_error(Excon::Error::NotFound) + end + end + end + + describe '#set_data' do + subject { data_store.set_data(model, data) } + + let(:data) { 'abc123' } + + context 'when data exists' do + let(:model) { create(:ci_build_trace_chunk, :fog_with_data, initial_data: 'sample data in fog') } + + it 'overwrites data' do + expect(data_store.data(model)).to eq('sample data in fog') + + subject + + expect(data_store.data(model)).to eq('abc123') + end + end + + context 'when data does not exist' do + let(:model) { create(:ci_build_trace_chunk, :fog_without_data) } + + it 'sets new data' do + expect { data_store.data(model) }.to raise_error(Excon::Error::NotFound) + + subject + + expect(data_store.data(model)).to eq('abc123') + end + end + end + + describe '#delete_data' do + subject { data_store.delete_data(model) } + + context 'when data exists' do + let(:model) { create(:ci_build_trace_chunk, :fog_with_data, initial_data: 'sample data in fog') } + + it 'deletes data' do + expect(data_store.data(model)).to eq('sample data in fog') + + subject + + expect { data_store.data(model) }.to raise_error(Excon::Error::NotFound) + end + end + + context 'when data does not exist' do + let(:model) { create(:ci_build_trace_chunk, :fog_without_data) } + + it 'does nothing' do + expect { data_store.data(model) }.to raise_error(Excon::Error::NotFound) + + subject + + expect { data_store.data(model) }.to raise_error(Excon::Error::NotFound) + end + end + end + + describe '#keys' do + subject { data_store.keys(relation) } + + let(:build) { create(:ci_build) } + let(:relation) { build.trace_chunks } + + before do + create(:ci_build_trace_chunk, :fog_with_data, chunk_index: 0, build: build) + create(:ci_build_trace_chunk, :fog_with_data, chunk_index: 1, build: build) + end + + it 'returns keys' do + is_expected.to eq([[build.id, 0], [build.id, 1]]) + end + end + + describe '#delete_keys' do + subject { data_store.delete_keys(keys) } + + let(:build) { create(:ci_build) } + let(:relation) { build.trace_chunks } + let(:keys) { data_store.keys(relation) } + + before do + create(:ci_build_trace_chunk, :fog_with_data, chunk_index: 0, build: build) + create(:ci_build_trace_chunk, :fog_with_data, chunk_index: 1, build: build) + end + + it 'deletes multiple data' do + ::Fog::Storage.new(JobArtifactUploader.object_store_credentials).tap do |connection| + expect(connection.get_object('artifacts', "tmp/builds/#{build.id}/chunks/0.log")[:body]).to be_present + expect(connection.get_object('artifacts', "tmp/builds/#{build.id}/chunks/1.log")[:body]).to be_present + end + + subject + + ::Fog::Storage.new(JobArtifactUploader.object_store_credentials).tap do |connection| + expect { connection.get_object('artifacts', "tmp/builds/#{build.id}/chunks/0.log")[:body] }.to raise_error(Excon::Error::NotFound) + expect { connection.get_object('artifacts', "tmp/builds/#{build.id}/chunks/1.log")[:body] }.to raise_error(Excon::Error::NotFound) + end + end + end +end diff --git a/spec/models/ci/build_trace_chunks/redis_spec.rb b/spec/models/ci/build_trace_chunks/redis_spec.rb new file mode 100644 index 00000000000..9da1e6a95ee --- /dev/null +++ b/spec/models/ci/build_trace_chunks/redis_spec.rb @@ -0,0 +1,132 @@ +require 'spec_helper' + +describe Ci::BuildTraceChunks::Redis, :clean_gitlab_redis_shared_state do + let(:data_store) { described_class.new } + + describe '#available?' do + subject { data_store.available? } + + it { is_expected.to be_truthy } + end + + describe '#data' do + subject { data_store.data(model) } + + context 'when data exists' do + let(:model) { create(:ci_build_trace_chunk, :redis_with_data, initial_data: 'sample data in redis') } + + it 'returns the data' do + is_expected.to eq('sample data in redis') + end + end + + context 'when data does not exist' do + let(:model) { create(:ci_build_trace_chunk, :redis_without_data) } + + it 'returns nil' do + is_expected.to be_nil + end + end + end + + describe '#set_data' do + subject { data_store.set_data(model, data) } + + let(:data) { 'abc123' } + + context 'when data exists' do + let(:model) { create(:ci_build_trace_chunk, :redis_with_data, initial_data: 'sample data in redis') } + + it 'overwrites data' do + expect(data_store.data(model)).to eq('sample data in redis') + + subject + + expect(data_store.data(model)).to eq('abc123') + end + end + + context 'when data does not exist' do + let(:model) { create(:ci_build_trace_chunk, :redis_without_data) } + + it 'sets new data' do + expect(data_store.data(model)).to be_nil + + subject + + expect(data_store.data(model)).to eq('abc123') + end + end + end + + describe '#delete_data' do + subject { data_store.delete_data(model) } + + context 'when data exists' do + let(:model) { create(:ci_build_trace_chunk, :redis_with_data, initial_data: 'sample data in redis') } + + it 'deletes data' do + expect(data_store.data(model)).to eq('sample data in redis') + + subject + + expect(data_store.data(model)).to be_nil + end + end + + context 'when data does not exist' do + let(:model) { create(:ci_build_trace_chunk, :redis_without_data) } + + it 'does nothing' do + expect(data_store.data(model)).to be_nil + + subject + + expect(data_store.data(model)).to be_nil + end + end + end + + describe '#keys' do + subject { data_store.keys(relation) } + + let(:build) { create(:ci_build) } + let(:relation) { build.trace_chunks } + + before do + create(:ci_build_trace_chunk, :redis_with_data, chunk_index: 0, build: build) + create(:ci_build_trace_chunk, :redis_with_data, chunk_index: 1, build: build) + end + + it 'returns keys' do + is_expected.to eq([[build.id, 0], [build.id, 1]]) + end + end + + describe '#delete_keys' do + subject { data_store.delete_keys(keys) } + + let(:build) { create(:ci_build) } + let(:relation) { build.trace_chunks } + let(:keys) { data_store.keys(relation) } + + before do + create(:ci_build_trace_chunk, :redis_with_data, chunk_index: 0, build: build) + create(:ci_build_trace_chunk, :redis_with_data, chunk_index: 1, build: build) + end + + it 'deletes multiple data' do + Gitlab::Redis::SharedState.with do |redis| + expect(redis.exists("gitlab:ci:trace:#{build.id}:chunks:0")).to be_truthy + expect(redis.exists("gitlab:ci:trace:#{build.id}:chunks:1")).to be_truthy + end + + subject + + Gitlab::Redis::SharedState.with do |redis| + expect(redis.exists("gitlab:ci:trace:#{build.id}:chunks:0")).to be_falsy + expect(redis.exists("gitlab:ci:trace:#{build.id}:chunks:1")).to be_falsy + end + end + end +end diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index efddd4e7662..2f87fb5f25d 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -25,7 +25,7 @@ describe Ci::JobArtifact do end it 'does not schedule the migration' do - expect(ObjectStorageUploadWorker).not_to receive(:perform_async) + expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async) subject end diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb index 2d75422ee68..da26d802688 100644 --- a/spec/models/concerns/cache_markdown_field_spec.rb +++ b/spec/models/concerns/cache_markdown_field_spec.rb @@ -370,4 +370,20 @@ describe CacheMarkdownField do end end end + + describe CacheMarkdownField::MarkdownEngine do + subject { lambda { |version| CacheMarkdownField::MarkdownEngine.from_version(version) } } + + it 'returns :common_mark as a default' do + expect(subject.call(nil)).to eq :common_mark + end + + it 'returns :common_mark' do + expect(subject.call(CacheMarkdownField::CACHE_COMMONMARK_VERSION)).to eq :common_mark + end + + it 'returns :redcarpet' do + expect(subject.call(CacheMarkdownField::CACHE_REDCARPET_VERSION)).to eq :redcarpet + end + end end diff --git a/spec/models/concerns/cacheable_attributes_spec.rb b/spec/models/concerns/cacheable_attributes_spec.rb index c6331c5ec15..f8c2e29fadd 100644 --- a/spec/models/concerns/cacheable_attributes_spec.rb +++ b/spec/models/concerns/cacheable_attributes_spec.rb @@ -52,7 +52,7 @@ describe CacheableAttributes do describe '.cache_key' do it 'excludes cache attributes' do - expect(minimal_test_class.cache_key).to eq("TestClass:#{Gitlab::VERSION}:#{Gitlab.migrations_hash}:#{Rails.version}") + expect(minimal_test_class.cache_key).to eq("TestClass:#{Gitlab::VERSION}:#{Rails.version}") end end diff --git a/spec/models/import_export_upload_spec.rb b/spec/models/import_export_upload_spec.rb new file mode 100644 index 00000000000..58af84b8a08 --- /dev/null +++ b/spec/models/import_export_upload_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe ImportExportUpload do + subject { described_class.new(project: create(:project)) } + + shared_examples 'stores the Import/Export file' do |method| + it 'stores the import file' do + subject.public_send("#{method}=", fixture_file_upload('spec/fixtures/project_export.tar.gz')) + + subject.save! + + url = "/uploads/-/system/import_export_upload/#{method}/#{subject.id}/project_export.tar.gz" + + expect(subject.public_send(method).url).to eq(url) + end + end + + context 'import' do + it_behaves_like 'stores the Import/Export file', :import_file + end + + context 'export' do + it_behaves_like 'stores the Import/Export file', :export_file + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index c3aa6cd6fed..b9512b81678 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -2782,6 +2782,10 @@ describe Project do let(:legacy_project) { create(:project, :legacy_storage, :with_export) } let(:project) { create(:project, :with_export) } + before do + stub_feature_flags(import_export_object_storage: false) + end + it 'removes the exports directory for the project' do expect(File.exist?(project.export_path)).to be_truthy @@ -2830,12 +2834,14 @@ describe Project do let(:project) { create(:project, :with_export) } it 'removes the exported project file' do + stub_feature_flags(import_export_object_storage: false) + exported_file = project.export_project_path expect(File.exist?(exported_file)).to be_truthy - allow(FileUtils).to receive(:rm_f).and_call_original - expect(FileUtils).to receive(:rm_f).with(exported_file).and_call_original + allow(FileUtils).to receive(:rm_rf).and_call_original + expect(FileUtils).to receive(:rm_rf).with(exported_file).and_call_original project.remove_exported_project_file diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 9b5c290b9f9..d6d340bd806 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -9,7 +9,11 @@ describe GroupPolicy do let(:admin) { create(:admin) } let(:group) { create(:group, :private) } - let(:guest_permissions) { [:read_label, :read_group, :upload_file, :read_namespace] } + let(:guest_permissions) do + [:read_label, :read_group, :upload_file, :read_namespace, :read_group_activity, + :read_group_issues, :read_group_boards, :read_group_labels, :read_group_milestones, + :read_group_merge_requests] + end let(:reporter_permissions) { [:admin_label] } diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index eba39bb6ccc..1716d182782 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -306,6 +306,14 @@ describe API::MergeRequests do expect(json_response['changes_count']).to eq(merge_request.merge_request_diff.real_size) end + it 'exposes description and title html when render_html is true' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), render_html: true + + expect(response).to have_gitlab_http_status(200) + + expect(json_response).to include('title_html', 'description_html') + end + context 'merge_request_metrics' do before do merge_request.metrics.update!(merged_by: user, diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb index 3834d27d0a9..a4615bd081f 100644 --- a/spec/requests/api/project_export_spec.rb +++ b/spec/requests/api/project_export_spec.rb @@ -192,6 +192,13 @@ describe API::ProjectExport do context 'when upload complete' do before do FileUtils.rm_rf(project_after_export.export_path) + + if project_after_export.export_project_object_exists? + upload = project_after_export.import_export_upload + + upload.remove_export_file! + upload.save + end end it_behaves_like '404 response' do @@ -261,6 +268,22 @@ describe API::ProjectExport do it_behaves_like 'get project export download not found' end end + + context 'when an uploader is used' do + before do + stub_uploads_object_storage(ImportExportUploader) + + [project, project_finished, project_after_export].each do |p| + p.add_master(user) + + upload = ImportExportUpload.new(project: p) + upload.export_file = fixture_file_upload('spec/fixtures/project_export.tar.gz', "`/tar.gz") + upload.save! + end + end + + it_behaves_like 'get project download by strategy' + end end describe 'POST /projects/:project_id/export' do diff --git a/spec/services/import_export_clean_up_service_spec.rb b/spec/services/import_export_clean_up_service_spec.rb index 1875d0448cd..d5fcef1246f 100644 --- a/spec/services/import_export_clean_up_service_spec.rb +++ b/spec/services/import_export_clean_up_service_spec.rb @@ -11,7 +11,6 @@ describe ImportExportCleanUpService do path = '/invalid/path/' stub_repository_downloads_path(path) - expect(File).to receive(:directory?).with(path + tmp_import_export_folder).and_return(false).at_least(:once) expect(service).not_to receive(:clean_up_export_files) service.execute @@ -38,6 +37,24 @@ describe ImportExportCleanUpService do end end + context 'with uploader exports' do + it 'removes old files' do + upload = create(:import_export_upload, + updated_at: 2.days.ago, + export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz')) + + expect { service.execute }.to change { upload.reload.export_file.file.nil? }.to(true) + end + + it 'does not remove new files' do + upload = create(:import_export_upload, + updated_at: 1.hour.ago, + export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz')) + + expect { service.execute }.not_to change { upload.reload.export_file.file.nil? } + end + end + def in_directory_with_files(mtime:) Dir.mktmpdir do |tmpdir| stub_repository_downloads_path(tmpdir) diff --git a/spec/services/merge_requests/conflicts/list_service_spec.rb b/spec/services/merge_requests/conflicts/list_service_spec.rb index d57852615d9..97da8a88660 100644 --- a/spec/services/merge_requests/conflicts/list_service_spec.rb +++ b/spec/services/merge_requests/conflicts/list_service_spec.rb @@ -84,23 +84,5 @@ describe MergeRequests::Conflicts::ListService do expect(service.can_be_resolved_in_ui?).to be_falsey end - - context 'with gitaly disabled', :skip_gitaly_mock do - it 'returns a falsey value when the MR has a missing ref after a force push' do - merge_request = create_merge_request('conflict-resolvable') - service = conflicts_service(merge_request) - allow_any_instance_of(Rugged::Repository).to receive(:merge_commits).and_raise(Rugged::OdbError) - - expect(service.can_be_resolved_in_ui?).to be_falsey - end - - it 'returns a falsey value when the MR has a missing revision after a force push' do - merge_request = create_merge_request('conflict-resolvable') - service = conflicts_service(merge_request) - allow(merge_request).to receive_message_chain(:target_branch_head, :raw, :id).and_return(Gitlab::Git::BLANK_SHA) - - expect(service.can_be_resolved_in_ui?).to be_falsey - end - end end end diff --git a/spec/services/merge_requests/conflicts/resolve_service_spec.rb b/spec/services/merge_requests/conflicts/resolve_service_spec.rb index cff09237005..7edf8a96c94 100644 --- a/spec/services/merge_requests/conflicts/resolve_service_spec.rb +++ b/spec/services/merge_requests/conflicts/resolve_service_spec.rb @@ -123,17 +123,6 @@ describe MergeRequests::Conflicts::ResolveService do expect(merge_request_from_fork.source_branch_head.parents.map(&:id)) .to eq(['404fa3fc7c2c9b5dacff102f353bdf55b1be2813', target_head]) end - - context 'when gitaly is disabled', :skip_gitaly_mock do - it 'gets conflicts from the source project' do - # REFACTOR NOTE: We used to test that `project.repository.rugged` wasn't - # used in this case, but since the refactor, for simplification, - # we always use that repository for read only operations. - expect(forked_project.repository.rugged).to receive(:merge_commits).and_call_original - - subject - end - end end end diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb index 64a9559791f..81dc7c57f4a 100644 --- a/spec/services/preview_markdown_service_spec.rb +++ b/spec/services/preview_markdown_service_spec.rb @@ -64,4 +64,16 @@ describe PreviewMarkdownService do expect(result[:commands]).to eq 'Sets time estimate to 2y.' end end + + it 'sets correct markdown engine' do + service = described_class.new(project, user, { markdown_version: CacheMarkdownField::CACHE_REDCARPET_VERSION }) + result = service.execute + + expect(result[:markdown_engine]).to eq :redcarpet + + service = described_class.new(project, user, { markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION }) + result = service.execute + + expect(result[:markdown_engine]).to eq :common_mark + end end diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb index 6fd73a50511..e98df375d48 100644 --- a/spec/services/projects/autocomplete_service_spec.rb +++ b/spec/services/projects/autocomplete_service_spec.rb @@ -131,4 +131,58 @@ describe Projects::AutocompleteService do end end end + + describe '#labels_as_hash' do + def expect_labels_to_equal(labels, expected_labels) + expect(labels.size).to eq(expected_labels.size) + extract_title = lambda { |label| label['title'] } + expect(labels.map(&extract_title)).to eq(expected_labels.map(&extract_title)) + end + + let(:user) { create(:user) } + let(:group) { create(:group, :nested) } + let!(:sub_group) { create(:group, parent: group) } + let(:project) { create(:project, :public, group: group) } + let(:issue) { create(:issue, project: project) } + + let!(:label1) { create(:label, project: project) } + let!(:label2) { create(:label, project: project) } + let!(:sub_group_label) { create(:group_label, group: sub_group) } + let!(:parent_group_label) { create(:group_label, group: group.parent) } + + before do + create(:group_member, group: group, user: user) + end + + it 'returns labels from project and ancestor groups' do + service = described_class.new(project, user) + results = service.labels_as_hash + expected_labels = [label1, label2, parent_group_label] + + expect_labels_to_equal(results, expected_labels) + end + + context 'some labels are already assigned' do + before do + issue.labels << label1 + end + + it 'marks already assigned as set' do + service = described_class.new(project, user) + results = service.labels_as_hash(issue) + expected_labels = [label1, label2, parent_group_label] + + expect_labels_to_equal(results, expected_labels) + + assigned_label_titles = issue.labels.map(&:title) + results.each do |hash| + if assigned_label_titles.include?(hash['title']) + expect(hash[:set]).to eq(true) + else + expect(hash.key?(:set)).to eq(false) + end + end + end + end + end end diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb index 471b0a74a19..58b5c6a6435 100644 --- a/spec/support/helpers/stub_object_storage.rb +++ b/spec/support/helpers/stub_object_storage.rb @@ -25,6 +25,11 @@ module StubObjectStorage ::Fog::Storage.new(connection_params).tap do |connection| begin connection.directories.create(key: remote_directory) + + # Cleanup remaining files + connection.directories.each do |directory| + directory.files.map(&:destroy) + end rescue Excon::Error::Conflict end end diff --git a/spec/support/matchers/disallow_request_matchers.rb b/spec/support/matchers/disallow_request_matchers.rb new file mode 100644 index 00000000000..db4d90e4fd0 --- /dev/null +++ b/spec/support/matchers/disallow_request_matchers.rb @@ -0,0 +1,15 @@ +RSpec::Matchers.define :disallow_request do + match do |middleware| + alert = middleware.env['rack.session'].to_hash + .dig('flash', 'flashes', 'alert') + + alert&.include?('You cannot perform write operations') + end +end + +RSpec::Matchers.define :disallow_request_in_json do + match do |response| + json_response = JSON.parse(response.body) + response.body.include?('You cannot perform write operations') && json_response.key?('message') + end +end diff --git a/spec/uploaders/import_export_uploader_spec.rb b/spec/uploaders/import_export_uploader_spec.rb new file mode 100644 index 00000000000..51b173b682d --- /dev/null +++ b/spec/uploaders/import_export_uploader_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe ImportExportUploader do + let(:model) { build_stubbed(:import_export_upload) } + let(:upload) { create(:upload, model: model) } + + subject { described_class.new(model, :import_file) } + + context "object_store is REMOTE" do + before do + stub_uploads_object_storage + end + + include_context 'with storage', described_class::Store::REMOTE + + it_behaves_like 'builds correct paths', + store_dir: %r[import_export_upload/import_file/], + upload_path: %r[import_export_upload/import_file/] + end +end diff --git a/spec/workers/object_storage_upload_worker_spec.rb b/spec/workers/object_storage_upload_worker_spec.rb deleted file mode 100644 index 32ddcbe9757..00000000000 --- a/spec/workers/object_storage_upload_worker_spec.rb +++ /dev/null @@ -1,108 +0,0 @@ -require 'spec_helper' - -describe ObjectStorageUploadWorker do - let(:local) { ObjectStorage::Store::LOCAL } - let(:remote) { ObjectStorage::Store::REMOTE } - - def perform - described_class.perform_async(uploader_class.name, subject_class, file_field, subject_id) - end - - context 'for LFS' do - let!(:lfs_object) { create(:lfs_object, :with_file, file_store: local) } - let(:uploader_class) { LfsObjectUploader } - let(:subject_class) { LfsObject } - let(:file_field) { :file } - let(:subject_id) { lfs_object.id } - - context 'when object storage is enabled' do - before do - stub_lfs_object_storage(background_upload: true) - end - - it 'uploads object to storage' do - expect { perform }.to change { lfs_object.reload.file_store }.from(local).to(remote) - end - - context 'when background upload is disabled' do - before do - allow(Gitlab.config.lfs.object_store).to receive(:background_upload) { false } - end - - it 'is skipped' do - expect { perform }.not_to change { lfs_object.reload.file_store } - end - end - end - - context 'when object storage is disabled' do - before do - stub_lfs_object_storage(enabled: false) - end - - it "doesn't migrate files" do - perform - - expect(lfs_object.reload.file_store).to eq(local) - end - end - end - - context 'for legacy artifacts' do - let(:build) { create(:ci_build, :legacy_artifacts) } - let(:uploader_class) { LegacyArtifactUploader } - let(:subject_class) { Ci::Build } - let(:file_field) { :artifacts_file } - let(:subject_id) { build.id } - - context 'when local storage is used' do - let(:store) { local } - - context 'and remote storage is defined' do - before do - stub_artifacts_object_storage(background_upload: true) - end - - it "migrates file to remote storage" do - perform - - expect(build.reload.artifacts_file_store).to eq(remote) - end - - context 'for artifacts_metadata' do - let(:file_field) { :artifacts_metadata } - - it 'migrates metadata to remote storage' do - perform - - expect(build.reload.artifacts_metadata_store).to eq(remote) - end - end - end - end - end - - context 'for job artifacts' do - let(:artifact) { create(:ci_job_artifact, :archive) } - let(:uploader_class) { JobArtifactUploader } - let(:subject_class) { Ci::JobArtifact } - let(:file_field) { :file } - let(:subject_id) { artifact.id } - - context 'when local storage is used' do - let(:store) { local } - - context 'and remote storage is defined' do - before do - stub_artifacts_object_storage(background_upload: true) - end - - it "migrates file to remote storage" do - perform - - expect(artifact.reload.file_store).to eq(remote) - end - end - end - end -end diff --git a/spec/workers/repository_check/batch_worker_spec.rb b/spec/workers/repository_check/batch_worker_spec.rb index 6bc551be9ad..ede271b2cdd 100644 --- a/spec/workers/repository_check/batch_worker_spec.rb +++ b/spec/workers/repository_check/batch_worker_spec.rb @@ -62,4 +62,12 @@ describe RepositoryCheck::BatchWorker do expect(subject.perform(shard_name)).to eq([]) end + + it 'does not run if the exclusive lease is taken' do + allow(subject).to receive(:try_obtain_lease).and_return(false) + + expect(subject).not_to receive(:perform_repository_checks) + + subject.perform(shard_name) + end end diff --git a/spec/workers/repository_check/dispatch_worker_spec.rb b/spec/workers/repository_check/dispatch_worker_spec.rb index 20a4f1f5344..7877429aa8f 100644 --- a/spec/workers/repository_check/dispatch_worker_spec.rb +++ b/spec/workers/repository_check/dispatch_worker_spec.rb @@ -11,6 +11,14 @@ describe RepositoryCheck::DispatchWorker do subject.perform end + it 'does nothing if the exclusive lease is taken' do + allow(subject).to receive(:try_obtain_lease).and_return(false) + + expect(RepositoryCheck::BatchWorker).not_to receive(:perform_async) + + subject.perform + end + it 'dispatches work to RepositoryCheck::BatchWorker' do expect(RepositoryCheck::BatchWorker).to receive(:perform_async).at_least(:once) diff --git a/yarn.lock b/yarn.lock index 30d49ad276a..d844ae4f8e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -78,9 +78,9 @@ lodash "^4.2.0" to-fast-properties "^2.0.0" -"@gitlab-org/gitlab-svgs@^1.24.0": - version "1.24.0" - resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.24.0.tgz#3b2b58c5a1d58ce784f486d648bd87cbbb06cedc" +"@gitlab-org/gitlab-svgs@^1.25.0": + version "1.25.0" + resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.25.0.tgz#1a82b1be43e1a46e6b0767ef46f26f5fd6bbd101" "@sindresorhus/is@^0.7.0": version "0.7.0" @@ -5272,9 +5272,9 @@ moment@2.x, moment@^2.18.1: version "2.19.2" resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.2.tgz#8a7f774c95a64550b4c7ebd496683908f9419dbe" -monaco-editor-webpack-plugin@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-1.2.1.tgz#577ed091420f422bb8f0ff3a8899dd82344da56d" +monaco-editor-webpack-plugin@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-1.4.0.tgz#7324258ab3695464cfe3bc12edb2e8c55b80d92f" monaco-editor@0.13.1: version "0.13.1" |