diff options
236 files changed, 2732 insertions, 1356 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ee9eaeae723..b93a79de994 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -386,20 +386,25 @@ flaky-examples-check: - scripts/merge-reports ${NEW_FLAKY_SPECS_REPORT} rspec_flaky/new_*_*.json - scripts/detect-new-flaky-examples $NEW_FLAKY_SPECS_REPORT +.assets-compile-cache: &assets-compile-cache + cache: + key: "assets-compile:vendor_ruby:.yarn-cache:tmp_cache_assets_sprockets:v3" + paths: + - vendor/ruby/ + - .yarn-cache/ + - tmp/cache/assets/sprockets + compile-assets: <<: *dedicated-runner <<: *except-docs <<: *use-pg stage: prepare - cache: - <<: *default-cache script: - node --version - - date - yarn install --frozen-lockfile --cache-folder .yarn-cache - - date - free -m - bundle exec rake gitlab:assets:compile + - scripts/clean-old-cached-assets variables: # we override the max_old_space_size to prevent OOM errors NODE_OPTIONS: --max_old_space_size=3584 @@ -408,6 +413,7 @@ compile-assets: paths: - node_modules - public/assets + <<: *assets-compile-cache setup-test-env: <<: *dedicated-runner @@ -628,7 +634,9 @@ gitlab:setup-mysql: gitlab:assets:compile: <<: *dedicated-no-docs-pull-cache-job image: dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-git-2.18-chrome-71.0-node-8.x-yarn-1.12-graphicsmagick-1.3.29-docker-18.06.1 - dependencies: [] + dependencies: + - setup-test-env + - compile-assets services: - docker:stable-dind variables: @@ -642,18 +650,19 @@ gitlab:assets:compile: DOCKER_DRIVER: overlay2 DOCKER_HOST: tcp://docker:2375 script: - - date + - node --version - yarn install --frozen-lockfile --production --cache-folder .yarn-cache - - date - free -m - bundle exec rake gitlab:assets:compile - - scripts/build_assets_image + - time scripts/build_assets_image + - scripts/clean-old-cached-assets artifacts: name: webpack-report expire_in: 31d paths: - webpack-report/ - public/assets/ + <<: *assets-compile-cache only: - //@gitlab-org/gitlab-ce - //@gitlab-org/gitlab-ee diff --git a/.gitlab/merge_request_templates/Security Release.md b/.gitlab/merge_request_templates/Security Release.md index d72b4eb1cb6..9a0979f27a7 100644 --- a/.gitlab/merge_request_templates/Security Release.md +++ b/.gitlab/merge_request_templates/Security Release.md @@ -9,7 +9,7 @@ See [the general developer security release guidelines](https://gitlab.com/gitla <!-- Mention the issue(s) this MR is related to --> -## Author's checklist +## Developer checklist - [ ] Link to the developer security workflow issue on `dev.gitlab.org` - [ ] MR targets `master` or `security-X-Y` for backports @@ -20,7 +20,7 @@ See [the general developer security release guidelines](https://gitlab.com/gitla - [ ] Add a link to an EE MR if required - [ ] Assign to a reviewer -## Reviewers checklist +## Reviewer checklist - [ ] Correct milestone is applied and the title is matching across all backports - [ ] Assigned to `@gitlab-release-tools-bot` with passing CI pipelines diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index 88c5fb891dc..bc80560fad6 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -1.4.0 +1.5.0 diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index 0e79152459e..fbb9ea12de3 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -8.1.1 +8.2.0 @@ -116,7 +116,6 @@ gem 'html-pipeline', '~> 2.8' gem 'deckar01-task_list', '2.2.0' gem 'gitlab-markup', '~> 1.6.5' gem 'github-markup', '~> 1.7.0', require: 'github/markup' -gem 'redcarpet', '~> 3.4' gem 'commonmarker', '~> 0.17' gem 'RedCloth', '~> 4.3.2' gem 'rdoc', '~> 6.0' diff --git a/Gemfile.lock b/Gemfile.lock index e6b563b5cb8..a9244fd853c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -682,7 +682,6 @@ GEM recaptcha (3.0.0) json recursive-open-struct (1.1.0) - redcarpet (3.4.0) redis (3.3.5) redis-actionpack (5.0.2) actionpack (>= 4.0, < 6) @@ -1118,7 +1117,6 @@ DEPENDENCIES rdoc (~> 6.0) re2 (~> 1.1.1) recaptcha (~> 3.0) - redcarpet (~> 3.4) redis (~> 3.2) redis-namespace (~> 1.6.0) redis-rails (~> 5.0.2) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index d1396b6c4bc..85eb08cc97d 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -5,6 +5,7 @@ import axios from './lib/utils/axios_utils'; const Api = { groupsPath: '/api/:version/groups.json', groupPath: '/api/:version/groups/:id', + groupMembersPath: '/api/:version/groups/:id/members', subgroupsPath: '/api/:version/groups/:id/subgroups', namespacesPath: '/api/:version/namespaces.json', groupProjectsPath: '/api/:version/groups/:id/projects.json', @@ -40,6 +41,12 @@ const Api = { }); }, + groupMembers(id) { + const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url); + }, + // Return groups list. Filtered by query groups(query, options, callback = $.noop) { const url = Api.buildUrl(Api.groupsPath); diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 56293d5f96f..d1d75658181 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -1,11 +1,10 @@ -import installCustomElements from 'document-register-element'; +import 'document-register-element'; import isEmojiUnicodeSupported from '../emoji/support'; -installCustomElements(window); +class GlEmoji extends HTMLElement { + constructor() { + super(); -export default function installGlEmojiElement() { - const GlEmojiElementProto = Object.create(HTMLElement.prototype); - GlEmojiElementProto.createdCallback = function createdCallback() { const emojiUnicode = this.textContent.trim(); const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset; @@ -43,9 +42,11 @@ export default function installGlEmojiElement() { }); } } - }; + } +} - document.registerElement('gl-emoji', { - prototype: GlEmojiElementProto, - }); +export default function installGlEmojiElement() { + if (!customElements.get('gl-emoji')) { + customElements.define('gl-emoji', GlEmoji); + } } diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index 35f1bb6b080..7adccbb062f 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -28,16 +28,13 @@ 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); @@ -67,16 +64,6 @@ 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/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index d4c1b07093d..f0ce2579ee7 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -129,6 +129,10 @@ export default { created() { this.adjustView(); eventHub.$once('fetchedNotesData', this.setDiscussions); + eventHub.$once('fetchDiffData', this.fetchData); + }, + beforeDestroy() { + eventHub.$off('fetchDiffData', this.fetchData); }, methods: { ...mapActions(['startTaskList']), diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 0b3def3d29d..a0f09932593 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -13,39 +13,17 @@ export default { Icon, FileRow, }, - data() { - return { - search: '', - }; - }, computed: { ...mapState('diffs', ['tree', 'addedLines', 'removedLines', 'renderTreeList']), ...mapGetters('diffs', ['allBlobs', 'diffFilesLength']), filteredTreeList() { - const search = this.search.toLowerCase().trim(); - - if (search === '') return this.renderTreeList ? this.tree : this.allBlobs; - - return this.allBlobs.reduce((acc, folder) => { - const tree = folder.tree.filter(f => f.path.toLowerCase().indexOf(search) >= 0); - - if (tree.length) { - return acc.concat({ - ...folder, - tree, - }); - } - - return acc; - }, []); + return this.renderTreeList ? this.tree : this.allBlobs; }, }, methods: { - ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']), - clearSearch() { - this.search = ''; - }, + ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile', 'toggleFileFinder']), }, + shortcutKeyCharacter: `${/Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl'}+P`, FileRowStats, }; </script> @@ -55,21 +33,17 @@ export default { <div class="append-bottom-8 position-relative tree-list-search d-flex"> <div class="flex-fill d-flex"> <icon name="search" class="position-absolute tree-list-icon" /> - <input - v-model="search" - :placeholder="s__('MergeRequest|Filter files')" - type="search" - class="form-control" - /> <button - v-show="search" - :aria-label="__('Clear search')" type="button" - class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0" - @click="clearSearch" + class="form-control text-left text-secondary" + @click="toggleFileFinder(true)" > - <icon name="close" /> + {{ s__('MergeRequest|Search files') }} </button> + <span + class="position-absolute text-secondary diff-tree-search-shortcut" + v-html="$options.shortcutKeyCharacter" + ></span> </div> </div> <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll"> @@ -104,4 +78,15 @@ export default { .tree-list-blobs .file-row-name { margin-left: 12px; } + +.diff-tree-search-shortcut { + top: 50%; + right: 10px; + transform: translateY(-50%); + pointer-events: none; +} + +.tree-list-icon { + pointer-events: none; +} </style> diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 094e5cdea9c..63954d9d412 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -1,11 +1,60 @@ import Vue from 'vue'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getParameterValues } from '~/lib/utils/url_utility'; +import FindFile from '~/vue_shared/components/file_finder/index.vue'; +import eventHub from '../notes/event_hub'; import diffsApp from './components/app.vue'; import { TREE_LIST_STORAGE_KEY } from './constants'; export default function initDiffsApp(store) { + const fileFinderEl = document.getElementById('js-diff-file-finder'); + + if (fileFinderEl) { + // eslint-disable-next-line no-new + new Vue({ + el: fileFinderEl, + store, + computed: { + ...mapState('diffs', ['fileFinderVisible', 'isLoading']), + ...mapGetters('diffs', ['flatBlobsList']), + }, + watch: { + fileFinderVisible(newVal, oldVal) { + if (newVal && !oldVal && !this.flatBlobsList.length) { + eventHub.$emit('fetchDiffData'); + } + }, + }, + methods: { + ...mapActions('diffs', ['toggleFileFinder', 'scrollToFile']), + openFile(file) { + window.mrTabs.tabShown('diffs'); + this.scrollToFile(file.path); + }, + }, + render(createElement) { + return createElement(FindFile, { + props: { + files: this.flatBlobsList, + visible: this.fileFinderVisible, + loading: this.isLoading, + showDiffStats: true, + clearSearchOnClose: false, + }, + on: { + toggle: this.toggleFileFinder, + click: this.openFile, + }, + class: ['diff-file-finder'], + style: { + display: this.fileFinderVisible ? '' : 'none', + }, + }); + }, + }); + } + return new Vue({ el: '#js-diffs-app', name: 'MergeRequestDiffs', diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 2c5019fb652..7fb66ce433b 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -296,5 +296,9 @@ export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = fals } }; +export const toggleFileFinder = ({ commit }, visible) => { + commit(types.TOGGLE_FILE_FINDER_VISIBLE, visible); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 86c0c7190f9..0e1ad654a2b 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -74,24 +74,25 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) = export const getDiffFileByHash = state => fileHash => state.diffFiles.find(file => file.file_hash === fileHash); -export const allBlobs = state => - Object.values(state.treeEntries) - .filter(f => f.type === 'blob') - .reduce((acc, file) => { - const { parentPath } = file; - - if (parentPath && !acc.some(f => f.path === parentPath)) { - acc.push({ - path: parentPath, - isHeader: true, - tree: [], - }); - } - - acc.find(f => f.path === parentPath).tree.push(file); - - return acc; - }, []); +export const flatBlobsList = state => + Object.values(state.treeEntries).filter(f => f.type === 'blob'); + +export const allBlobs = (state, getters) => + getters.flatBlobsList.reduce((acc, file) => { + const { parentPath } = file; + + if (parentPath && !acc.some(f => f.path === parentPath)) { + acc.push({ + path: parentPath, + isHeader: true, + tree: [], + }); + } + + acc.find(f => f.path === parentPath).tree.push(file); + + return acc; + }, []); export const diffFilesLength = state => state.diffFiles.length; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 05b4c552f6e..6ee33d9fc6d 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -29,4 +29,5 @@ export default () => ({ highlightedRow: null, renderTreeList: true, showWhitespace: true, + fileFinderVisible: false, }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index e760b4d1079..71ad108ce88 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -22,3 +22,4 @@ export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW'; export const SET_TREE_DATA = 'SET_TREE_DATA'; export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST'; export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE'; +export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 4aeb393b29b..7bbafe66199 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -244,4 +244,7 @@ export default { [types.SET_SHOW_WHITESPACE](state, showWhitespace) { state.showWhitespace = showWhitespace; }, + [types.TOGGLE_FILE_FINDER_VISIBLE](state, visible) { + state.fileFinderVisible = visible; + }, }; diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index caec8779cac..9894ebb0624 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,20 +1,17 @@ <script> import Vue from 'vue'; -import Mousetrap from 'mousetrap'; import { mapActions, mapState, mapGetters } from 'vuex'; import { __ } from '~/locale'; +import FindFile from '~/vue_shared/components/file_finder/index.vue'; import NewModal from './new_dropdown/modal.vue'; import IdeSidebar from './ide_side_bar.vue'; import RepoTabs from './repo_tabs.vue'; import IdeStatusBar from './ide_status_bar.vue'; import RepoEditor from './repo_editor.vue'; -import FindFile from './file_finder/index.vue'; import RightPane from './panes/right.vue'; import ErrorMessage from './error_message.vue'; import CommitEditorHeader from './commit_sidebar/editor_header.vue'; -const originalStopCallback = Mousetrap.stopCallback; - export default { components: { NewModal, @@ -42,21 +39,18 @@ export default { 'emptyStateSvgPath', 'currentProjectId', 'errorMessage', + 'loading', + ]), + ...mapGetters([ + 'activeFile', + 'hasChanges', + 'someUncommittedChanges', + 'isCommitModeActive', + 'allBlobs', ]), - ...mapGetters(['activeFile', 'hasChanges', 'someUncommittedChanges', 'isCommitModeActive']), }, mounted() { window.onbeforeunload = e => this.onBeforeUnload(e); - - Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => { - if (e.preventDefault) { - e.preventDefault(); - } - - this.toggleFileFinder(!this.fileFindVisible); - }); - - Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo); }, methods: { ...mapActions(['toggleFileFinder']), @@ -70,17 +64,8 @@ export default { }); return returnValue; }, - mousetrapStopCallback(e, el, combo) { - if ( - (combo === 't' && el.classList.contains('dropdown-input-field')) || - el.classList.contains('inputarea') - ) { - return true; - } else if (combo === 'command+p' || combo === 'ctrl+p') { - return false; - } - - return originalStopCallback(e, el, combo); + openFile(file) { + this.$router.push(`/project${file.url}`); }, }, }; @@ -90,7 +75,14 @@ export default { <article class="ide position-relative d-flex flex-column align-items-stretch"> <error-message v-if="errorMessage" :message="errorMessage" /> <div class="ide-view flex-grow d-flex"> - <find-file v-show="fileFindVisible" /> + <find-file + v-show="fileFindVisible" + :files="allBlobs" + :visible="fileFindVisible" + :loading="loading" + @toggle="toggleFileFinder" + @click="openFile" + /> <ide-sidebar /> <div class="multi-file-edit-pane"> <template v-if="activeFile"> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 09245ed0296..804ebae4555 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -1,8 +1,3 @@ -// Fuzzy file finder -export const MAX_FILE_FINDER_RESULTS = 40; -export const FILE_FINDER_ROW_HEIGHT = 55; -export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33; - export const MAX_WINDOW_HEIGHT_COMPACT = 750; // Commit message textarea diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index d97a950a8b2..24c2f71ae2b 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -202,7 +202,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo dispatch( 'setErrorMessage', { - text: __('An error accured whilst committing your changes.'), + text: __('An error occurred whilst committing your changes.'), action: () => dispatch('commitChanges').then(() => dispatch('setErrorMessage', null, { root: true }), diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index fea7f0d77a5..bd757a76ee7 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -108,11 +108,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, projectPath: { type: String, required: true, @@ -313,7 +308,6 @@ export default { :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 90258c0e044..299130e56ae 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -20,11 +20,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, canAttachFile: { type: Boolean, required: false, @@ -48,7 +43,6 @@ export default { <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 45b60bc3392..eade31f1d14 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -39,11 +39,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, projectPath: { type: String, required: true, @@ -101,7 +96,6 @@ export default { :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/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index ae8b4b4d635..0ceff10a02a 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -221,6 +221,22 @@ export const scrollToElement = element => { }; /** + * Returns a function that can only be invoked once between + * each browser screen repaint. + * @param {Function} fn + */ +export const debounceByAnimationFrame = fn => { + let requestId; + + return function debounced(...args) { + if (requestId) { + window.cancelAnimationFrame(requestId); + } + requestId = window.requestAnimationFrame(() => fn.apply(this, args)); + }; +}; + +/** this will take in the `name` of the param you want to parse in the url if the name does not exist this function will return `null` otherwise it will return the value of the param key provided diff --git a/app/assets/javascripts/lib/utils/grammar.js b/app/assets/javascripts/lib/utils/grammar.js new file mode 100644 index 00000000000..18f9e2ed846 --- /dev/null +++ b/app/assets/javascripts/lib/utils/grammar.js @@ -0,0 +1,40 @@ +import { sprintf, s__ } from '~/locale'; + +/** + * Combines each given item into a noun series sentence fragment. It does this + * in a way that supports i18n by giving context and punctuation to the locale + * functions. + * + * **Examples:** + * + * - `["A", "B"] => "A and B"` + * - `["A", "B", "C"] => "A, B, and C"` + * + * **Why only nouns?** + * + * Some languages need a bit more context to translate other series. + * + * @param {String[]} items + */ +export const toNounSeriesText = items => { + if (items.length === 0) { + return ''; + } else if (items.length === 1) { + return items[0]; + } else if (items.length === 2) { + return sprintf(s__('nounSeries|%{firstItem} and %{lastItem}'), { + firstItem: items[0], + lastItem: items[1], + }); + } + + return items.reduce((item, nextItem, idx) => + idx === items.length - 1 + ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }) + : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }), + ); +}; + +export default { + toNounSeriesText, +}; diff --git a/app/assets/javascripts/lib/utils/icon_utils.js b/app/assets/javascripts/lib/utils/icon_utils.js new file mode 100644 index 00000000000..7b8dd9bbef7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/icon_utils.js @@ -0,0 +1,18 @@ +/* eslint-disable import/prefer-default-export */ + +import axios from '~/lib/utils/axios_utils'; + +/** + * Retrieve SVG icon path content from gitlab/svg sprite icons + * @param {String} name + */ +export const getSvgIconPathContent = name => + axios + .get(gon.sprite_icons) + .then(({ data: svgs }) => + new DOMParser() + .parseFromString(svgs, 'text/xml') + .querySelector(`#${name} path`) + .getAttribute('d'), + ) + .catch(() => null); diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index e2cffe0b4b4..a0d6b91ff02 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -1,6 +1,10 @@ <script> -import { GlAreaChart } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; + +let debouncedResize; export default { components: { @@ -26,22 +30,32 @@ export default { ); }, }, + containerWidth: { + type: Number, + required: true, + }, + deploymentData: { + type: Array, + required: false, + default: () => [], + }, alertData: { type: Object, required: false, default: () => ({}), }, }, + data() { + return { + width: 0, + height: 0, + scatterSymbol: undefined, + }; + }, computed: { chartData() { return this.graphData.queries.reduce((accumulator, query) => { - const xLabel = `${query.unit}`; - accumulator[xLabel] = {}; - query.result.forEach(res => - res.values.forEach(v => { - accumulator[xLabel][v.time.toISOString()] = v.value; - }), - ); + accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []); return accumulator; }, {}); }, @@ -51,14 +65,17 @@ export default { name: 'Time', type: 'time', axisLabel: { - formatter: date => dateFormat(date, 'h:MMtt'), + formatter: date => dateFormat(date, 'h:MM TT'), + }, + axisPointer: { + snap: true, }, nameTextStyle: { padding: [18, 0, 0, 0], }, }, yAxis: { - name: this.graphData.y_label, + name: this.yAxisLabel, axisLabel: { formatter: value => value.toFixed(3), }, @@ -69,33 +86,104 @@ export default { legend: { formatter: this.xAxisLabel, }, + series: this.scatterSeries, + }; + }, + earliestDatapoint() { + return Object.values(this.chartData).reduce((acc, data) => { + const [[timestamp]] = data.sort(([a], [b]) => { + if (a < b) { + return -1; + } + return a > b ? 1 : 0; + }); + + return timestamp < acc || acc === null ? timestamp : acc; + }, null); + }, + recentDeployments() { + return this.deploymentData.reduce((acc, deployment) => { + if (deployment.created_at >= this.earliestDatapoint) { + acc.push({ + id: deployment.id, + createdAt: deployment.created_at, + sha: deployment.sha, + commitUrl: `${this.projectPath}/commit/${deployment.sha}`, + tag: deployment.tag, + tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null, + ref: deployment.ref.name, + showDeploymentFlag: false, + }); + } + + return acc; + }, []); + }, + scatterSeries() { + return { + type: 'scatter', + data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]), + symbol: this.scatterSymbol, + symbolSize: 14, }; }, xAxisLabel() { return this.graphData.queries.map(query => query.label).join(', '); }, + yAxisLabel() { + const [query] = this.graphData.queries; + return `${this.graphData.y_label} (${query.unit})`; + }, + }, + watch: { + containerWidth: 'onResize', + }, + beforeDestroy() { + window.removeEventListener('resize', debouncedResize); + }, + created() { + debouncedResize = debounceByAnimationFrame(this.onResize); + window.addEventListener('resize', debouncedResize); + this.getScatterSymbol(); }, methods: { formatTooltipText(params) { const [date, value] = params; return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)]; }, + getScatterSymbol() { + getSvgIconPathContent('rocket') + .then(path => { + if (path) { + this.scatterSymbol = `path://${path}`; + } + }) + .catch(() => {}); + }, + onResize() { + const { width, height } = this.$refs.areaChart.$el.getBoundingClientRect(); + this.width = width; + this.height = height; + }, }, }; </script> <template> - <div class="prometheus-graph"> + <div class="prometheus-graph col-12 col-lg-6"> <div class="prometheus-graph-header"> <h5 class="prometheus-graph-title">{{ graphData.title }}</h5> <div class="prometheus-graph-widgets"><slot></slot></div> </div> <gl-area-chart + ref="areaChart" v-bind="$attrs" :data="chartData" :option="chartOptions" :format-tooltip-text="formatTooltipText" :thresholds="alertData" + :width="width" + :height="height" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 0b4bb9cc686..9c5fd93f7d1 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,5 +1,4 @@ <script> -import _ from 'underscore'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import Flash from '../../flash'; @@ -9,6 +8,9 @@ import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; import MonitoringStore from '../stores/monitoring_store'; +const sidebarAnimationDuration = 150; +let sidebarMutationObserver; + export default { components: { MonitorAreaChart, @@ -89,39 +91,29 @@ export default { elWidth: 0, }; }, - computed: { - forceRedraw() { - return this.elWidth; - }, - }, created() { this.service = new MonitoringService({ metricsEndpoint: this.metricsEndpoint, deploymentEndpoint: this.deploymentEndpoint, environmentsEndpoint: this.environmentsEndpoint, }); - this.mutationObserverConfig = { - attributes: true, - childList: false, - subtree: false, - }; }, beforeDestroy() { - window.removeEventListener('resize', this.resizeThrottled, false); - this.sidebarMutationObserver.disconnect(); + if (sidebarMutationObserver) { + sidebarMutationObserver.disconnect(); + } }, mounted() { - this.resizeThrottled = _.debounce(this.resize, 100); if (!this.hasMetrics) { this.state = 'gettingStarted'; } else { this.getGraphsData(); - window.addEventListener('resize', this.resizeThrottled, false); - - const sidebarEl = document.querySelector('.nav-sidebar'); - // The sidebar listener - this.sidebarMutationObserver = new MutationObserver(this.resizeThrottled); - this.sidebarMutationObserver.observe(sidebarEl, this.mutationObserverConfig); + sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); + sidebarMutationObserver.observe(document.querySelector('.layout-page'), { + attributes: true, + childList: false, + subtree: false, + }); } }, methods: { @@ -149,20 +141,21 @@ export default { this.showEmptyState = false; }) - .then(this.resize) .catch(() => { this.state = 'unableToConnect'; }); }, - resize() { - this.elWidth = this.$el.clientWidth; + onSidebarMutation() { + setTimeout(() => { + this.elWidth = this.$el.clientWidth; + }, sidebarAnimationDuration); }, }, }; </script> <template> - <div v-if="!showEmptyState" :key="forceRedraw" class="prometheus-graphs prepend-top-default"> + <div v-if="!showEmptyState" class="prometheus-graphs prepend-top-default"> <div class="environments d-flex align-items-center"> {{ s__('Metrics|Environment') }} <div class="dropdown prepend-left-10"> @@ -197,7 +190,9 @@ export default { v-for="(graphData, graphIndex) in groupData.metrics" :key="graphIndex" :graph-data="graphData" + :deployment-data="store.deploymentData" :alert-data="getGraphAlerts(graphData.id)" + :container-width="elWidth" group-id="monitor-area-chart" /> </graph-group> diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 96ecc5ab8a8..70635059bd9 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -13,7 +13,7 @@ function checkQueryEmptyData(query) { result: query.result.filter(timeSeries => { const newTimeSeries = timeSeries; const hasValue = series => - !Number.isNaN(series.value) && (series.value !== null || series.value !== undefined); + !Number.isNaN(series[1]) && (series[1] !== null || series[1] !== undefined); const hasNonNullValue = timeSeries.values.find(hasValue); newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : []; @@ -33,10 +33,10 @@ function normalizeMetrics(metrics) { ...query, result: query.result.map(result => ({ ...result, - values: result.values.map(([timestamp, value]) => ({ - time: new Date(timestamp * 1000), - value: Number(value), - })), + values: result.values.map(([timestamp, value]) => [ + new Date(timestamp * 1000).toISOString(), + Number(value), + ]), })), })); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index c3443c300e3..c9c01354333 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1239,15 +1239,13 @@ 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-markdown-version', markdownVersion); + .attr('data-remote', 'true'); $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 d669ba5a8fa..1d6cb9485f7 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -39,11 +39,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, }, data() { return { @@ -342,7 +337,6 @@ 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 diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue new file mode 100644 index 00000000000..ea590905e3c --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue @@ -0,0 +1,17 @@ +<script> +export default { + name: 'ReplyPlaceholder', +}; +</script> + +<template> + <button + ref="button" + type="button" + class="js-vue-discussion-reply btn btn-text-field" + :title="s__('MergeRequests|Add a reply')" + @click="$emit('onClick')" + > + {{ s__('MergeRequests|Reply...') }} + </button> +</template> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index bcf5d334da4..ff303d0f55a 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -111,7 +111,6 @@ export default { :line="line" :note="note" :help-page-path="helpPagePath" - :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 269b4a4b117..92258a25438 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -26,11 +26,6 @@ export default { required: false, default: '', }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, saveButtonTitle: { type: String, required: false, @@ -202,7 +197,6 @@ export default { <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :markdown-version="markdownVersion" :quick-actions-docs-path="quickActionsDocsPath" :line="line" :note="discussionNote" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 695efe3602f..e26cce1c47f 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -24,6 +24,7 @@ import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import discussionNavigation from '../mixins/discussion_navigation'; +import ReplyPlaceholder from './discussion_reply_placeholder.vue'; import jumpToNextDiscussionButton from './discussion_jump_to_next_button.vue'; export default { @@ -39,6 +40,7 @@ export default { resolveDiscussionButton, jumpToNextDiscussionButton, toggleRepliesWidget, + ReplyPlaceholder, placeholderNote, placeholderSystemNote, systemNote, @@ -447,14 +449,7 @@ Please check your network connection and try again.`; > <template v-if="!isReplying && canReply"> <div class="discussion-with-resolve-btn"> - <button - type="button" - class="js-vue-discussion-reply btn btn-text-field qa-discussion-reply" - title="Add a reply" - @click="showReplyForm" - > - Reply... - </button> + <reply-placeholder class="qa-discussion-reply" @onClick="showReplyForm" /> <resolve-discussion-button v-if="discussion.resolvable" :is-resolving="isResolving" diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index f3fcfdfda05..5edceea043c 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -44,11 +44,6 @@ export default { required: false, default: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, helpPagePath: { type: String, required: false, @@ -216,10 +211,6 @@ export default { </template> </ul> - <comment-form - v-if="!commentsDisabled" - :noteable-type="noteableType" - :markdown-version="markdownVersion" - /> + <comment-form v-if="!commentsDisabled" :noteable-type="noteableType" /> </div> </template> diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 2f715c85fa6..4883266dae5 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -18,7 +18,6 @@ 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 = parseInt(notesDataset.markdownVersion, 10); let currentUserData = {}; noteableData.noteableType = notesDataset.noteableType; @@ -37,7 +36,6 @@ document.addEventListener('DOMContentLoaded', () => { return { noteableData, currentUserData, - markdownVersion, notesData: JSON.parse(notesDataset.notesData), }; }, @@ -47,7 +45,6 @@ document.addEventListener('DOMContentLoaded', () => { noteableData: this.noteableData, notesData: this.notesData, userData: this.currentUserData, - markdownVersion: this.markdownVersion, }, }); }, diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index c7ce4675573..0dd0d5336fc 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import createFlash from '~/flash'; import GfmAutoComplete from '~/gfm_auto_complete'; +import emojiRegex from 'emoji-regex'; import EmojiMenu from './emoji_menu'; const defaultStatusEmoji = 'speech_balloon'; @@ -42,6 +43,17 @@ document.addEventListener('DOMContentLoaded', () => { const emojiAutocomplete = new GfmAutoComplete(); emojiAutocomplete.setup($(statusMessageField), { emojis: true }); + const userNameInput = document.getElementById('user_name'); + userNameInput.addEventListener('input', () => { + const EMOJI_REGEX = emojiRegex(); + if (EMOJI_REGEX.test(userNameInput.value)) { + // set field to invalid so it gets detected by GlFieldErrors + userNameInput.setCustomValidity('Invalid field'); + } else { + userNameInput.setCustomValidity(''); + } + }); + import(/* webpackChunkName: 'emoji' */ '~/emoji') .then(Emoji => { const emojiMenu = new EmojiMenu( 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 0ce9d271845..57c4dfbe3b7 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 @@ -142,8 +142,8 @@ export default { } }, methods: { - createService(store) { - const endpoints = { + getServiceEndpoints(store) { + return { mergePath: store.mergePath, mergeCheckPath: store.mergeCheckPath, cancelAutoMergePath: store.cancelAutoMergePath, @@ -154,7 +154,9 @@ export default { mergeActionsContentPath: store.mergeActionsContentPath, rebasePath: store.rebasePath, }; - return new MRWidgetService(endpoints); + }, + createService(store) { + return new MRWidgetService(this.getServiceEndpoints(store)); }, checkStatus(cb, isRebased) { return this.service diff --git a/app/assets/javascripts/ide/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index 0b0cd7b75eb..b57455adaad 100644 --- a/app/assets/javascripts/ide/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -1,45 +1,62 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import Mousetrap from 'mousetrap'; import VirtualList from 'vue-virtual-scroll-list'; import Item from './item.vue'; -import router from '../../ide_router'; -import { - MAX_FILE_FINDER_RESULTS, - FILE_FINDER_ROW_HEIGHT, - FILE_FINDER_EMPTY_ROW_HEIGHT, -} from '../../constants'; -import { - UP_KEY_CODE, - DOWN_KEY_CODE, - ENTER_KEY_CODE, - ESC_KEY_CODE, -} from '../../../lib/utils/keycodes'; +import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; + +export const MAX_FILE_FINDER_RESULTS = 40; +export const FILE_FINDER_ROW_HEIGHT = 55; +export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33; + +const originalStopCallback = Mousetrap.stopCallback; export default { components: { Item, VirtualList, }, + props: { + files: { + type: Array, + required: true, + }, + visible: { + type: Boolean, + required: true, + }, + loading: { + type: Boolean, + required: true, + }, + showDiffStats: { + type: Boolean, + required: false, + default: false, + }, + clearSearchOnClose: { + type: Boolean, + required: false, + default: true, + }, + }, data() { return { - focusedIndex: 0, + focusedIndex: -1, searchText: '', mouseOver: false, cancelMouseOver: false, }; }, computed: { - ...mapGetters(['allBlobs']), - ...mapState(['fileFindVisible', 'loading']), filteredBlobs() { const searchText = this.searchText.trim(); if (searchText === '') { - return this.allBlobs.slice(0, MAX_FILE_FINDER_RESULTS); + return this.files.slice(0, MAX_FILE_FINDER_RESULTS); } - return fuzzaldrinPlus.filter(this.allBlobs, searchText, { + return fuzzaldrinPlus.filter(this.files, searchText, { key: 'path', maxResults: MAX_FILE_FINDER_RESULTS, }); @@ -58,10 +75,12 @@ export default { }, }, watch: { - fileFindVisible() { + visible() { this.$nextTick(() => { - if (!this.fileFindVisible) { - this.searchText = ''; + if (!this.visible) { + if (this.clearSearchOnClose) { + this.searchText = ''; + } } else { this.focusedIndex = 0; @@ -72,7 +91,11 @@ export default { }); }, searchText() { - this.focusedIndex = 0; + this.focusedIndex = -1; + + this.$nextTick(() => { + this.focusedIndex = 0; + }); }, focusedIndex() { if (!this.mouseOver) { @@ -98,8 +121,25 @@ export default { } }, }, + mounted() { + if (this.files.length) { + this.focusedIndex = 0; + } + + Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => { + if (e.preventDefault) { + e.preventDefault(); + } + + this.toggle(!this.visible); + }); + + Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo); + }, methods: { - ...mapActions(['toggleFileFinder']), + toggle(visible) { + this.$emit('toggle', visible); + }, clearSearchInput() { this.searchText = ''; @@ -139,15 +179,15 @@ export default { this.openFile(this.filteredBlobs[this.focusedIndex]); break; case ESC_KEY_CODE: - this.toggleFileFinder(false); + this.toggle(false); break; default: break; } }, openFile(file) { - this.toggleFileFinder(false); - router.push(`/project${file.url}`); + this.toggle(false); + this.$emit('click', file); }, onMouseOver(index) { if (!this.cancelMouseOver) { @@ -159,14 +199,26 @@ export default { this.cancelMouseOver = false; this.onMouseOver(index); }, + mousetrapStopCallback(e, el, combo) { + if ( + (combo === 't' && el.classList.contains('dropdown-input-field')) || + el.classList.contains('inputarea') + ) { + return true; + } else if (combo === 'command+p' || combo === 'ctrl+p') { + return false; + } + + return originalStopCallback(e, el, combo); + }, }, }; </script> <template> - <div class="ide-file-finder-overlay" @mousedown.self="toggleFileFinder(false)"> - <div class="dropdown-menu diff-file-changes ide-file-finder show"> - <div class="dropdown-input"> + <div class="file-finder-overlay" @mousedown.self="toggle(false)"> + <div class="dropdown-menu diff-file-changes file-finder show"> + <div :class="{ 'has-value': showClearInputButton }" class="dropdown-input"> <input ref="searchInput" v-model="searchText" @@ -186,9 +238,6 @@ export default { ></i> <i :aria-label="__('Clear search input')" - :class="{ - show: showClearInputButton, - }" role="button" class="fa fa-times dropdown-input-clear" @click="clearSearchInput" @@ -203,6 +252,7 @@ export default { :search-text="searchText" :focused="index === focusedIndex" :index="index" + :show-diff-stats="showDiffStats" class="disable-hover" @click="openFile" @mouseover="onMouseOver" @@ -225,3 +275,25 @@ export default { </div> </div> </template> + +<style scoped> +.file-finder-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 200; +} + +.file-finder { + top: 10px; + left: 50%; + transform: translateX(-50%); +} + +.diff-file-changes { + top: 50px; + max-height: 327px; +} +</style> diff --git a/app/assets/javascripts/ide/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue index 83e80d50aff..73511879ff2 100644 --- a/app/assets/javascripts/ide/components/file_finder/item.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue @@ -1,5 +1,6 @@ <script> import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import Icon from '~/vue_shared/components/icon.vue'; import FileIcon from '../../../vue_shared/components/file_icon.vue'; import ChangedFileIcon from '../../../vue_shared/components/changed_file_icon.vue'; @@ -7,6 +8,7 @@ const MAX_PATH_LENGTH = 60; export default { components: { + Icon, ChangedFileIcon, FileIcon, }, @@ -27,6 +29,11 @@ export default { type: Number, required: true, }, + showDiffStats: { + type: Boolean, + required: false, + default: false, + }, }, computed: { pathWithEllipsis() { @@ -97,8 +104,23 @@ export default { </span> </span> </span> - <span v-if="file.changed || file.tempFile" class="diff-changed-stats"> - <changed-file-icon :file="file" /> + <span v-if="file.changed || file.tempFile" v-once class="diff-changed-stats"> + <span v-if="showDiffStats"> + <span class="cgreen bold"> + <icon name="file-addition" class="align-text-top" /> {{ file.addedLines }} + </span> + <span class="cred bold ml-1"> + <icon name="file-deletion" class="align-text-top" /> {{ file.removedLines }} + </span> + </span> + <changed-file-icon v-else :file="file" /> </span> </button> </template> + +<style scoped> +.highlighted { + color: #1f78d1; + font-weight: 600; +} +</style> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index cc07ef46064..3f607aa2a0a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -27,11 +27,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, addSpacingClasses: { type: Boolean, required: false, @@ -158,7 +153,7 @@ export default { this.markdownPreviewLoading = true; this.markdownPreview = __('Loading…'); this.$http - .post(this.versionedPreviewPath(), { text }) + .post(this.markdownPreviewPath, { text }) .then(resp => resp.json()) .then(data => this.renderMarkdown(data)) .catch(() => new Flash(__('Error loading markdown preview'))); @@ -186,13 +181,6 @@ export default { .then(() => $(this.$refs['markdown-preview']).renderGFM()) .catch(() => new Flash(__('Error rendering markdown preview'))); }, - - versionedPreviewPath() { - const { markdownPreviewPath, markdownVersion } = this; - return `${markdownPreviewPath}${ - markdownPreviewPath.indexOf('?') === -1 ? '?' : '&' - }markdown_version=${markdownVersion}`; - }, }, }; </script> diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 08d84f7748f..cb449b642e7 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -89,6 +89,10 @@ hr { .str-truncated { @include str-truncated; + &-30 { + @include str-truncated(30%); + } + &-60 { @include str-truncated(60%); } @@ -390,9 +394,12 @@ img.emoji { /** COMMON SIZING CLASSES **/ .w-0 { width: 0; } -.h-13em { height: 13em; } + +.h-12em { height: 12em; } + .mw-460 { max-width: 460px; } .mw-6em { max-width: 6em; } + .min-height-0 { min-height: 0; } /** COMMON SPACING CLASSES **/ diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 1f24b8dfa9e..2ac98b5d18f 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -816,26 +816,6 @@ $ide-commit-header-height: 48px; z-index: 1; } -.ide-file-finder-overlay { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 100; -} - -.ide-file-finder { - top: 10px; - left: 50%; - transform: translateX(-50%); - - .highlighted { - color: $blue-500; - font-weight: $gl-font-weight-bold; - } -} - .ide-commit-message-field { height: 200px; background-color: $white-light; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index b6abb792709..61ecf133b02 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -240,18 +240,7 @@ } .prometheus-graph { - flex: 1 0 auto; - min-width: 450px; - max-width: 100%; padding: $gl-padding / 2; - - h5 { - font-size: 16px; - } - - @include media-breakpoint-down(sm) { - min-width: 100%; - } } .prometheus-graph-header { @@ -261,6 +250,7 @@ margin-bottom: $gl-padding-8; h5 { + font-size: $gl-font-size-large; margin: 0; } } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 53afb182b54..38a7e199c6a 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -986,3 +986,9 @@ width: $ci-action-icon-size-lg; } } + +.merge-request-details .file-finder-overlay.diff-file-finder { + position: fixed; + z-index: 99999; + background: $black-transparent; +} diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 4b0f0b8255c..f72d25fc54c 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -16,8 +16,6 @@ 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/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb new file mode 100644 index 00000000000..372c803278d --- /dev/null +++ b/app/controllers/concerns/record_user_last_activity.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# == RecordUserLastActivity +# +# Controller concern that updates the `last_activity_on` field of `users` +# for any authenticated GET request. The DB update will only happen once per day. +# +# In order to determine if you should include this concern or not, please check the +# description and discussion on this issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/54947 +module RecordUserLastActivity + include CookiesHelper + extend ActiveSupport::Concern + + included do + before_action :set_user_last_activity + end + + def set_user_last_activity + return unless request.get? + return unless Feature.enabled?(:set_user_last_activity, default_enabled: true) + return if Gitlab::Database.read_only? + + if current_user && current_user.last_activity_on != Date.today + Users::ActivityService.new(current_user, "visited #{request.path}").execute + end + end +end diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb index 515a9eede8e..9ca54c5519b 100644 --- a/app/controllers/concerns/send_file_upload.rb +++ b/app/controllers/concerns/send_file_upload.rb @@ -3,16 +3,19 @@ module SendFileUpload def send_upload(file_upload, send_params: {}, redirect_params: {}, attachment: nil, proxy: false, disposition: 'attachment') if attachment + response_disposition = ::Gitlab::ContentDisposition.format(disposition: 'attachment', filename: attachment) + # Response-Content-Type will not override an existing Content-Type in # Google Cloud Storage, so the metadata needs to be cleared on GCS for # this to work. However, this override works with AWS. - redirect_params[:query] = { "response-content-disposition" => "#{disposition};filename=#{attachment.inspect}", + redirect_params[:query] = { "response-content-disposition" => response_disposition, "response-content-type" => guess_content_type(attachment) } # By default, Rails will send uploads with an extension of .js with a # content-type of text/javascript, which will trigger Rails' # cross-origin JavaScript protection. send_params[:content_type] = 'text/plain' if File.extname(attachment) == '.js' - send_params.merge!(filename: attachment, disposition: disposition) + + send_params.merge!(filename: attachment, disposition: utf8_encoded_disposition(disposition, attachment)) end if file_upload.file_storage? @@ -25,6 +28,18 @@ module SendFileUpload end end + # Since Rails 5 doesn't properly support support non-ASCII filenames, + # we have to add our own to ensure RFC 5987 compliance. However, Rails + # 5 automatically appends `filename#{filename}` here: + # https://github.com/rails/rails/blob/v5.0.7/actionpack/lib/action_controller/metal/data_streaming.rb#L137 + # Rails 6 will have https://github.com/rails/rails/pull/33829, so we + # can get rid of this special case handling when we upgrade. + def utf8_encoded_disposition(disposition, filename) + content = ::Gitlab::ContentDisposition.new(disposition: disposition, filename: filename) + + "#{disposition}; #{content.utf8_filename}" + end + def guess_content_type(filename) types = MIME::Types.type_for(filename) diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index c6ae4fe15bf..48451bedcc2 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -72,7 +72,7 @@ module ServiceParams dynamic_params = @service.event_channel_names + @service.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables service_params = params.permit(:id, service: allowed_service_params + dynamic_params) - if service_params[:service].is_a?(Hash) + if service_params[:service].is_a?(ActionController::Parameters) FILTER_BLANK_PARAMS.each do |param| service_params[:service].delete(param) if service_params[:service][param].blank? end diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb index cee0753a021..0e9fdc60363 100644 --- a/app/controllers/dashboard/application_controller.rb +++ b/app/controllers/dashboard/application_controller.rb @@ -2,6 +2,7 @@ class Dashboard::ApplicationController < ApplicationController include ControllerWithCrossProjectAccessCheck + include RecordUserLastActivity layout 'dashboard' diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index cdc6f53df8e..51fdb6c05fb 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -2,6 +2,7 @@ class Groups::BoardsController < Groups::ApplicationController include BoardsResponses + include RecordUserLastActivity before_action :assign_endpoint_vars before_action :boards, only: :index diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 15aadf3f74b..4e50106398a 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -5,6 +5,7 @@ class GroupsController < Groups::ApplicationController include IssuableCollectionsAction include ParamsBackwardCompatibility include PreviewMarkdown + include RecordUserLastActivity respond_to :html diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index f8e482937d5..97120273d6b 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -4,7 +4,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController include AuthenticatesWithTwoFactor include Devise::Controllers::Rememberable - protect_from_forgery except: [:kerberos, :saml, :cas3], prepend: true + protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true def handle_omniauth omniauth_flow(Gitlab::Auth::OAuth) diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 1a1b024d766..4e85de25c6b 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -182,11 +182,11 @@ class Projects::EnvironmentsController < Projects::ApplicationController end def serialize_environments(request, response, nested = false) - serializer = EnvironmentSerializer + EnvironmentSerializer .new(project: @project, current_user: @current_user) + .tap { |serializer| serializer.within_folders if nested } .with_pagination(request, response) - serializer = serializer.within_folders if nested - serializer.represent(@environments) + .represent(@environments) end def authorize_stop_environment! diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index f9a80aa3cfb..b9d02a62fc3 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -8,6 +8,7 @@ class Projects::IssuesController < Projects::ApplicationController include IssuableCollections include IssuesCalendar include SpammableActions + include RecordUserLastActivity def self.issue_except_actions %i[index calendar new create bulk_update import_csv] diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index bc0a3d3526d..7c4dc95529a 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -7,6 +7,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include RendersCommits include ToggleAwardEmoji include IssuableCollections + include RecordUserLastActivity skip_before_action :merge_request, only: [:index, :bulk_update] before_action :whitelist_query_limiting, only: [:assign_related_issues, :update] diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index d3af35723ac..33c6608d321 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -6,6 +6,7 @@ class ProjectsController < Projects::ApplicationController include ExtractsPath include PreviewMarkdown include SendFileUpload + include RecordUserLastActivity prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 1a69ec85d18..3e08c0ccd8f 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -18,6 +18,7 @@ # assignee_id: integer or 'None' or 'Any' # assignee_username: string # search: string +# in: 'title', 'description', or a string joining them with comma # label_name: string # sort: string # non_archived: boolean @@ -56,6 +57,7 @@ class IssuableFinder milestone_title my_reaction_emoji search + in ] end @@ -408,7 +410,7 @@ class IssuableFinder items = klass.with(cte.to_arel).from(klass.table_name) end - items.full_search(search) + items.full_search(search, matched_columns: params[:in]) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 45e494725d7..a0504ca0879 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -14,6 +14,7 @@ # milestone_title: string # assignee_id: integer # search: string +# in: 'title', 'description', or a string joining them with comma # label_name: string # sort: string # my_reaction_emoji: string diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index e190d5d90c9..b645011a3c5 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -15,6 +15,7 @@ # author_id: integer # assignee_id: integer # search: string +# in: 'title', 'description', or a string joining them with comma # label_name: string # sort: string # non_archived: boolean diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 0fee29bf7c7..1a471034972 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -268,7 +268,6 @@ module IssuablesHelper issuableRef: issuable.to_reference, markdownPreviewPath: preview_markdown_path(parent), markdownDocsPath: help_page_path('user/markdown'), - markdownVersion: issuable.cached_markdown_version, lockVersion: issuable.lock_version, issuableTemplates: issuable_templates(issuable), initialTitleHtml: markdown_field(issuable, :title), diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 0d638b850b4..66f4b7b3f30 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -116,7 +116,6 @@ module MarkupHelper def markup(file_name, text, context = {}) context[:project] ||= @project - context[:markdown_engine] ||= :redcarpet unless commonmark_for_repositories_enabled? html = context.delete(:rendered) || markup_unsafe(file_name, text, context) prepare_for_rendering(html, context) end @@ -132,7 +131,6 @@ module MarkupHelper page_slug: wiki_page.slug, issuable_state_filter_enabled: true ) - context[:markdown_engine] ||= :redcarpet unless commonmark_for_repositories_enabled? html = case wiki_page.format @@ -187,10 +185,6 @@ module MarkupHelper end end - def commonmark_for_repositories_enabled? - Feature.enabled?(:commonmark_for_repositories, default_enabled: true) - end - private # Return +text+, truncated to +max_chars+ characters, excluding any HTML diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 293dd20ad49..aaf38cbfe70 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -171,7 +171,6 @@ 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/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 85248a16f50..c400302cda3 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -265,10 +265,6 @@ module ProjectsHelper link_to 'BFG', 'https://rtyley.github.io/bfg-repo-cleaner/', target: '_blank', rel: 'noopener noreferrer' end - def legacy_render_context(params) - params[:legacy_render] ? { markdown_engine: :redcarpet } : {} - end - def explore_projects_tab? current_page?(explore_projects_path) || current_page?(trending_explore_projects_path) || @@ -328,7 +324,7 @@ module ProjectsHelper def external_nav_tabs(project) [].tap do |tabs| tabs << :external_issue_tracker if project.external_issue_tracker - tabs << :external_wiki if project.has_external_wiki? + tabs << :external_wiki if project.external_wiki end end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 29696ab276f..c4e310e638d 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -6,4 +6,12 @@ class ApplicationRecord < ActiveRecord::Base def self.id_in(ids) where(id: ids) end + + def self.safe_find_or_create_by(*args) + transaction(requires_new: true) do + find_or_create_by(*args) + end + rescue ActiveRecord::RecordNotUnique + retry + end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 84010e40ef4..6b2b7e77180 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -48,13 +48,23 @@ module Ci delegate :trigger_short_token, to: :trigger_request, allow_nil: true ## - # The "environment" field for builds is a String, and is the unexpanded name! + # Since Gitlab 11.5, deployments records started being created right after + # `ci_builds` creation. We can look up a relevant `environment` through + # `deployment` relation today. This is much more efficient than expanding + # environment name with variables. + # (See more https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22380) # + # However, we have to still expand environment name if it's a stop action, + # because `deployment` persists information for start action only. + # + # We will follow up this by persisting expanded name in build metadata or + # persisting stop action in database. def persisted_environment return unless has_environment? strong_memoize(:persisted_environment) do - Environment.find_by(name: expanded_environment_name, project: project) + deployment&.environment || + Environment.find_by(name: expanded_environment_name, project: project) end end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 588204c7470..5fa6f79bdaa 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -13,7 +13,6 @@ module CacheMarkdownField extend ActiveSupport::Concern # Increment this number every time the renderer changes its output - CACHE_REDCARPET_VERSION = 3 CACHE_COMMONMARK_VERSION_START = 10 CACHE_COMMONMARK_VERSION = 14 @@ -42,18 +41,6 @@ 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 @@ -71,7 +58,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] = MarkdownEngine.from_version(latest_cached_markdown_version) + context[:markdown_engine] = :common_mark context end @@ -128,17 +115,7 @@ module CacheMarkdownField end def latest_cached_markdown_version - return CacheMarkdownField::CACHE_COMMONMARK_VERSION unless cached_markdown_version - - if legacy_markdown? - CacheMarkdownField::CACHE_REDCARPET_VERSION - else - CacheMarkdownField::CACHE_COMMONMARK_VERSION - end - end - - def legacy_markdown? - cached_markdown_version && cached_markdown_version.between?(1, CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - 1) + CacheMarkdownField::CACHE_COMMONMARK_VERSION end included do diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index b1cf03551f6..0a77fbeba08 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -136,10 +136,18 @@ module Issuable # This method uses ILIKE on PostgreSQL and LIKE on MySQL. # # query - The search query as a String + # matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma. # # Returns an ActiveRecord::Relation. - def full_search(query) - fuzzy_search(query, [:title, :description]) + def full_search(query, matched_columns: 'title,description') + allowed_columns = [:title, :description] + matched_columns = matched_columns.to_s.split(',').map(&:to_sym) + matched_columns &= allowed_columns + + # Matching title or description if the matched_columns did not contain any allowed columns. + matched_columns = [:title, :description] if matched_columns.empty? + + fuzzy_search(query, matched_columns) end def sort_by_attribute(method, excluded_labels: []) diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 7f4947ba27a..31084c54bdc 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -8,9 +8,13 @@ module ErrorTracking belongs_to :project - validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true } + validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true - validate :validate_api_url_path + validates :api_url, presence: true, if: :enabled + + validate :validate_api_url_path, if: :enabled + + validates :token, presence: true, if: :enabled attr_encrypted :token, mode: :per_attribute_iv, @@ -19,6 +23,31 @@ module ErrorTracking after_save :clear_reactive_cache! + def project_name + super || project_name_from_slug + end + + def organization_name + super || organization_name_from_slug + end + + def project_slug + project_slug_from_api_url + end + + def organization_slug + organization_slug_from_api_url + end + + def self.build_api_url_from(api_host:, project_slug:, organization_slug:) + uri = Addressable::URI.parse("#{api_host}/api/0/projects/#{organization_slug}/#{project_slug}/") + uri.path = uri.path.squeeze('/') + + uri.to_s + rescue Addressable::URI::InvalidURIError + api_host + end + def sentry_client Sentry::Client.new(api_url, token) end @@ -33,6 +62,10 @@ module ErrorTracking end end + def list_sentry_projects + { projects: sentry_client.list_projects } + end + def calculate_reactive_cache(request, opts) case request when 'list_issues' @@ -47,13 +80,53 @@ module ErrorTracking url.sub('api/0/projects/', '') end + def api_host + return if api_url.blank? + + # This returns http://example.com/ + Addressable::URI.join(api_url, '/').to_s + end + private + def project_name_from_slug + @project_name_from_slug ||= project_slug_from_api_url&.titleize + end + + def organization_name_from_slug + @organization_name_from_slug ||= organization_slug_from_api_url&.titleize + end + + def project_slug_from_api_url + extract_slug(:project) + end + + def organization_slug_from_api_url + extract_slug(:organization) + end + + def extract_slug(capture) + return if api_url.blank? + + begin + url = Addressable::URI.parse(api_url) + rescue Addressable::URI::InvalidURIError + return nil + end + + @slug_match ||= url.path.match(%r{^/api/0/projects/+(?<organization>[^/]+)/+(?<project>[^/|$]+)}) || {} + @slug_match[capture] + end + def validate_api_url_path - unless URI(api_url).path.starts_with?('/api/0/projects') - errors.add(:api_url, 'path needs to start with /api/0/projects') + return if api_url.blank? + + begin + unless Addressable::URI.parse(api_url).path.starts_with?('/api/0/projects') + errors.add(:api_url, 'path needs to start with /api/0/projects') + end + rescue Addressable::URI::InvalidURIError end - rescue URI::InvalidURIError end end end diff --git a/app/models/programming_language.rb b/app/models/programming_language.rb index 0e667dac21e..5f0f313b7f9 100644 --- a/app/models/programming_language.rb +++ b/app/models/programming_language.rb @@ -3,4 +3,10 @@ class ProgrammingLanguage < ActiveRecord::Base validates :name, presence: true validates :color, allow_blank: false, color: true + + # Returns all programming languages which match the given name (case + # insensitively). + scope :with_name_case_insensitive, ->(name) do + where(arel_table[:name].matches(sanitize_sql_like(name))) + end end diff --git a/app/models/project.rb b/app/models/project.rb index e55a57e46ef..d4e2ed883bc 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -389,6 +389,16 @@ class Project < ActiveRecord::Base with_project_feature.where(project_features: { access_level_attribute => level }) } + # Picks projects which use the given programming language + scope :with_programming_language, ->(language_name) do + lang_id_query = ProgrammingLanguage + .with_name_case_insensitive(language_name) + .select(:id) + + joins(:repository_languages) + .where(repository_languages: { programming_language_id: lang_id_query }) + end + scope :with_builds_enabled, -> { with_feature_enabled(:builds) } scope :with_issues_enabled, -> { with_feature_enabled(:issues) } scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) } @@ -1068,7 +1078,7 @@ class Project < ActiveRecord::Base # rubocop: disable CodeReuse/ServiceClass def create_labels Label.templates.each do |label| - params = label.attributes.except('id', 'template', 'created_at', 'updated_at') + params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type') Labels::FindOrCreateService.new(nil, self, params).execute(skip_authorization: true) end end diff --git a/app/models/repository.rb b/app/models/repository.rb index e6ab3b484a2..9ae13fbaa80 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -614,7 +614,6 @@ class Repository return unless readme context = { project: project } - context[:markdown_engine] = :redcarpet unless MarkupHelper.commonmark_for_repositories_enabled? MarkupHelper.markup_unsafe(readme.name, readme.data, context) end diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb index 99a0c54a26a..fd23cc9ac87 100644 --- a/app/models/ssh_host_key.rb +++ b/app/models/ssh_host_key.rb @@ -26,6 +26,7 @@ class SshHostKey self.reactive_cache_lifetime = 10.minutes def self.find_by(opts = {}) + opts = HashWithIndifferentAccess.new(opts) return nil unless opts.key?(:id) project_id, url = opts[:id].split(':', 2) @@ -54,7 +55,7 @@ class SshHostKey # Needed for reactive caching def self.primary_key - 'id' + :id end def id diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index f8d8ef04001..699b3e8555e 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -7,14 +7,17 @@ module Ci CreateError = Class.new(StandardError) SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build, + Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs, Gitlab::Ci::Pipeline::Chain::Validate::Abilities, Gitlab::Ci::Pipeline::Chain::Validate::Repository, Gitlab::Ci::Pipeline::Chain::Validate::Config, Gitlab::Ci::Pipeline::Chain::Skip, + Gitlab::Ci::Pipeline::Chain::Limit::Size, Gitlab::Ci::Pipeline::Chain::Populate, - Gitlab::Ci::Pipeline::Chain::Create].freeze + Gitlab::Ci::Pipeline::Chain::Create, + Gitlab::Ci::Pipeline::Chain::Limit::Activity].freeze - def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, &block) + def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, **options, &block) @pipeline = Ci::Pipeline.new command = Gitlab::Ci::Pipeline::Chain::Command.new( @@ -32,7 +35,8 @@ module Ci variables_attributes: params[:variables_attributes], project: project, current_user: current_user, - push_options: params[:push_options]) + push_options: params[:push_options], + **extra_options(**options)) sequence = Gitlab::Ci::Pipeline::Chain::Sequence .new(pipeline, command, SEQUENCE) @@ -103,5 +107,9 @@ module Ci pipeline.project.source_of_merge_requests.opened.where(source_branch: pipeline.ref) end # rubocop: enable CodeReuse/ActiveRecord + + def extra_options + {} # overriden in EE + end end end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index de78a3f7b27..9ff1da270e2 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -11,6 +11,8 @@ module Groups return false unless valid_share_with_group_lock_change? + before_assignment_hook(group, params) + group.assign_attributes(params) begin @@ -28,6 +30,10 @@ module Groups private + def before_assignment_hook(group, params) + # overriden in EE + end + def after_update if group.previous_changes.include?(:visibility_level) && group.private? # don't enqueue immediately to prevent todos removal in case of a mistake diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 842d59d26a0..ef991eaf234 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -270,9 +270,7 @@ class IssuableBaseService < BaseService tasklist_toggler = TaskListToggleService.new(issuable.description, issuable.description_html, line_source: update_task_params[:line_source], line_number: update_task_params[:line_number].to_i, - toggle_as_checked: update_task_params[:checked], - index: update_task_params[:index].to_i, - sourcepos: !issuable.legacy_markdown?) + toggle_as_checked: update_task_params[:checked]) unless tasklist_toggler.execute # if we make it here, the data is much newer than we thought it was - fail fast diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index fe19abf50f6..ac51fee0b3f 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -63,6 +63,7 @@ module MergeRequests # UpdateMergeRequestsWorker could be retried by an exception. # MR pipelines should not be recreated in such case. return if merge_request.merge_request_pipeline_exists? + return if merge_request.has_no_commits? Ci::CreatePipelineService .new(merge_request.source_project, user, ref: merge_request.source_branch) diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index a449a5dc3e9..c1655c38095 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -10,8 +10,7 @@ class PreviewMarkdownService < BaseService text: text, users: users, suggestions: suggestions, - commands: commands.join(' '), - markdown_engine: markdown_engine + commands: commands.join(' ') ) end @@ -49,12 +48,4 @@ class PreviewMarkdownService < BaseService def commands_target_id params[:quick_actions_target_id] end - - def markdown_engine - if params[:legacy_render] - :redcarpet - else - CacheMarkdownField::MarkdownEngine.from_version(params[:markdown_version].to_i) - end - end end diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index abf40b3ad7a..674071ad92a 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -2,6 +2,8 @@ module Projects class UpdatePagesConfigurationService < BaseService + include Gitlab::Utils::StrongMemoize + attr_reader :project def initialize(project) @@ -9,15 +11,25 @@ module Projects end def execute - update_file(pages_config_file, pages_config.to_json) + if file_equals?(pages_config_file, pages_config_json) + return success(reload: false) + end + + update_file(pages_config_file, pages_config_json) reload_daemon - success + success(reload: true) rescue => e error(e.message) end private + def pages_config_json + strong_memoize(:pages_config_json) do + pages_config.to_json + end + end + def pages_config { domains: pages_domains_config, @@ -67,11 +79,6 @@ module Projects end def update_file(file, data) - unless data - FileUtils.remove(file, force: true) - return - end - temp_file = "#{file}.#{SecureRandom.hex(16)}" File.open(temp_file, 'w') do |f| f.write(data) @@ -81,5 +88,18 @@ module Projects # In case if the updating fails FileUtils.remove(temp_file, force: true) end + + def file_equals?(file, data) + existing_data = read_file(file) + data == existing_data.to_s + end + + def read_file(file) + File.open(file, 'r') do |f| + f.read + end + rescue + nil + end end end diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index cb1bf0a03a5..d6af26d949d 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -2,6 +2,8 @@ module Search class GlobalService + include Gitlab::Utils::StrongMemoize + attr_accessor :current_user, :params attr_reader :default_project_filter @@ -19,11 +21,15 @@ module Search @projects ||= ProjectsFinder.new(current_user: current_user).execute end - def scope - @scope ||= begin - allowed_scopes = %w[issues merge_requests milestones] + def allowed_scopes + strong_memoize(:allowed_scopes) do + %w[issues merge_requests milestones] + end + end - allowed_scopes.delete(params[:scope]) { 'projects' } + def scope + strong_memoize(:scope) do + allowed_scopes.include?(params[:scope]) ? params[:scope] : 'projects' end end end diff --git a/app/services/task_list_toggle_service.rb b/app/services/task_list_toggle_service.rb index 2717fc9035a..b5c4cd3235d 100644 --- a/app/services/task_list_toggle_service.rb +++ b/app/services/task_list_toggle_service.rb @@ -5,17 +5,13 @@ # We don't care if the text has changed above or below the specific checkbox, as long # the checkbox still exists at exactly the same line number and the text is equal. # If successful, new values are available in `updated_markdown` and `updated_markdown_html` -# -# Note: once we've removed RedCarpet support, we can remove the `index` and `sourcepos` -# parameters class TaskListToggleService attr_reader :updated_markdown, :updated_markdown_html - def initialize(markdown, markdown_html, line_source:, line_number:, toggle_as_checked:, index:, sourcepos: true) + def initialize(markdown, markdown_html, line_source:, line_number:, toggle_as_checked:) @markdown, @markdown_html = markdown, markdown_html @line_source, @line_number = line_source, line_number @toggle_as_checked = toggle_as_checked - @index, @use_sourcepos = index, sourcepos @updated_markdown, @updated_markdown_html = nil end @@ -28,8 +24,8 @@ class TaskListToggleService private - attr_reader :markdown, :markdown_html, :index, :toggle_as_checked - attr_reader :line_source, :line_number, :use_sourcepos + attr_reader :markdown, :markdown_html, :toggle_as_checked + attr_reader :line_source, :line_number def toggle_markdown source_lines = markdown.split("\n") @@ -68,17 +64,8 @@ class TaskListToggleService end # When using CommonMark, we should be able to use the embedded `sourcepos` attribute to - # target the exact line in the DOM. For RedCarpet, we need to use the index of the checkbox - # that was checked and match it with what we think is the same checkbox. - # The reason `sourcepos` is slightly more reliable is the case where a line of text is - # changed from a regular line into a checkbox (or vice versa). Then the checked index - # in the UI will be off from the list of checkboxes we've calculated locally. - # It's a rare circumstance, but since we can account for it, we do. + # target the exact line in the DOM. def get_html_checkbox(html) - if use_sourcepos - html.css(".task-list-item[data-sourcepos^='#{line_number}:'] > input.task-list-item-checkbox").first - else - html.css('.task-list-item-checkbox')[index - 1] - end + html.css(".task-list-item[data-sourcepos^='#{line_number}:'] > input.task-list-item-checkbox").first end end diff --git a/app/views/email_rejection_mailer/rejection.html.haml b/app/views/email_rejection_mailer/rejection.html.haml index c4ae7befe4e..666dab61f40 100644 --- a/app/views/email_rejection_mailer/rejection.html.haml +++ b/app/views/email_rejection_mailer/rejection.html.haml @@ -1,5 +1,5 @@ %p - Unfortunately, your email message to GitLab could not be processed. + = _("Unfortunately, your email message to GitLab could not be processed.") = markdown @reason = render_if_exists 'shared/additional_email_text' diff --git a/app/views/email_rejection_mailer/rejection.text.haml b/app/views/email_rejection_mailer/rejection.text.haml index 0e13b2a6473..8d940ef1293 100644 --- a/app/views/email_rejection_mailer/rejection.text.haml +++ b/app/views/email_rejection_mailer/rejection.text.haml @@ -1,4 +1,4 @@ -Unfortunately, your email message to GitLab could not be processed. += _("Unfortunately, your email message to GitLab could not be processed.") \ = @reason = render_if_exists 'shared/additional_email_text' diff --git a/app/views/instance_statistics/conversational_development_index/_callout.html.haml b/app/views/instance_statistics/conversational_development_index/_callout.html.haml index 33a4dab1e00..a4256e23979 100644 --- a/app/views/instance_statistics/conversational_development_index/_callout.html.haml +++ b/app/views/instance_statistics/conversational_development_index/_callout.html.haml @@ -2,12 +2,12 @@ .user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } } .bordered-box.landing.content-block %button.btn.btn-default.close.js-close-callout{ type: 'button', - 'aria-label' => 'Dismiss ConvDev introduction' } + 'aria-label' => _('Dismiss ConvDev introduction') } = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') .user-callout-copy %h4 - Introducing Your Conversational Development Index + = _('Introducing Your Conversational Development Index') %p - Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers. + = _('Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.') .svg-container.convdev = custom_icon('convdev_overview') diff --git a/app/views/instance_statistics/conversational_development_index/_card.html.haml b/app/views/instance_statistics/conversational_development_index/_card.html.haml index 57eda06630b..76af55dcf7a 100644 --- a/app/views/instance_statistics/conversational_development_index/_card.html.haml +++ b/app/views/instance_statistics/conversational_development_index/_card.html.haml @@ -9,11 +9,11 @@ .board-card-score .board-card-score-value = format_score(card.instance_score) - .board-card-score-name You + .board-card-score-name= _('You') .board-card-score .board-card-score-value = format_score(card.leader_score) - .board-card-score-name Lead + .board-card-score-name= _('Lead') .board-card-score-big = number_to_percentage(card.percentage_score, precision: 1) .board-card-buttons diff --git a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml b/app/views/instance_statistics/conversational_development_index/_no_data.html.haml index dd795aee135..4e8f34cd574 100644 --- a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml +++ b/app/views/instance_statistics/conversational_development_index/_no_data.html.haml @@ -1,7 +1,7 @@ .container.convdev-empty .col-sm-12.justify-content-center.text-center = custom_icon('convdev_no_data') - %h4 Data is still calculating... + %h4= _('Data is still calculating...') %p - In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index. - = link_to 'Learn more', help_page_path('user/instance_statistics/convdev'), target: '_blank' + = _('In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.') + = link_to _('Learn more'), help_page_path('user/instance_statistics/convdev'), target: '_blank' diff --git a/app/views/instance_statistics/conversational_development_index/index.html.haml b/app/views/instance_statistics/conversational_development_index/index.html.haml index 1e7db4982d6..23f90b876a0 100644 --- a/app/views/instance_statistics/conversational_development_index/index.html.haml +++ b/app/views/instance_statistics/conversational_development_index/index.html.haml @@ -17,9 +17,9 @@ %h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" } = number_to_percentage(@metric.average_percentage_score, precision: 1) .convdev-header-subtitle - index + = _('index') %br - score + = _('score') = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/instance_statistics/convdev') .convdev-cards.board-card-container diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 2629b374e7c..753316b27e2 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -2,7 +2,7 @@ - @content_class = "limit-container-width" unless fluid_layout - gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host -= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f| += bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f| = form_errors(@user) .row @@ -77,10 +77,10 @@ .col-lg-8 .row - if @user.read_only_attribute?(:name) - = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' }, + = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name' }, help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) } - else - = f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, help: s_("Profiles|Enter your name, so people you know can recognize you") + = f.text_field :name, label: 'Full name', required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you") = f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' } - if @user.read_only_attribute?(:email) diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 7694217eb28..0be41b5888c 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -21,9 +21,14 @@ - if @project.tag_list.present? %span.home-panel-topic-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.tag_list.join(', ') : nil } = sprite_icon('tag', size: 16, css_class: 'icon append-right-4') - = @project.topics_to_show + + - @project.topics_to_show.each do |topic| + %a{ class: 'badge badge-pill badge-secondary append-right-5 str-truncated-30', href: explore_projects_path(tag: topic) } + = topic.titleize + - if @project.has_extra_topics? - = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown } + .text-nowrap + = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown } .project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml index 45e1d32980c..de4653dad2c 100644 --- a/app/views/projects/_wiki.html.haml +++ b/app/views/projects/_wiki.html.haml @@ -2,7 +2,7 @@ %div{ class: container_class } .prepend-top-default.append-bottom-default .wiki - = render_wiki_content(@wiki_home, legacy_render_context(params)) + = render_wiki_content(@wiki_home) - else - can_create_wiki = can?(current_user, :create_wiki, @project) .landing{ class: [('row-content-block row p-0 align-items-center' if can_create_wiki), ('content-block' unless can_create_wiki)] } diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 3f2d96b70e5..4520cca8cf5 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -21,7 +21,7 @@ Write %li - = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id, legacy_render: params[:legacy_render]) do + = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id) do = editing_preview_title(@blob.name) = form_tag(project_update_blob_path(@project, @id), method: :put, class: 'js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths(@project)) do diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml index ff460a3831c..66687f087ff 100644 --- a/app/views/projects/blob/preview.html.haml +++ b/app/views/projects/blob/preview.html.haml @@ -2,7 +2,7 @@ .diff-content - if markup?(@blob.name) .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } - = markup(@blob.name, @content, legacy_render_context(params)) + = markup(@blob.name, @content) - else .file-content.code.js-syntax-highlight - unless @diff_lines.empty? diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml index 6edbfd91b21..1a77eb078be 100644 --- a/app/views/projects/blob/viewers/_markup.html.haml +++ b/app/views/projects/blob/viewers/_markup.html.haml @@ -1,6 +1,4 @@ - blob = viewer.blob -- context = legacy_render_context(params) -- unless context[:markdown_engine] == :redcarpet - - context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup) +- context = blob.respond_to?(:rendered_markup) ? { rendered: blob.rendered_markup } : {} .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = markup(blob.name, blob.data, context) diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml index 1e4e9450ffa..1be1087b36f 100644 --- a/app/views/projects/issues/_form.html.haml +++ b/app/views/projects/issues/_form.html.haml @@ -1,4 +1,3 @@ = 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| + html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' } 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 13b967beba1..a7c9e54506d 100644 --- a/app/views/projects/merge_requests/_form.html.haml +++ b/app/views/projects/merge_requests/_form.html.haml @@ -1,4 +1,3 @@ = 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| + html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| = render 'shared/issuable/form', f: f, issuable: @merge_request, presenter: @mr_presenter diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 0b720e5d542..5111c9fab8d 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -59,6 +59,7 @@ #js-vue-discussion-counter .tab-content#diff-notes-app + #js-diff-file-finder #notes.notes.tab-pane.voting_notes .row %section.col-md-12 diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 19f5bba75c4..5cc6b5a173b 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -1,6 +1,5 @@ = 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| + html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| = form_errors(@milestone) .row .col-md-6 diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml index 52c6c7ec424..e4efeed04f0 100644 --- a/app/views/projects/tags/releases/edit.html.haml +++ b/app/views/projects/tags/releases/edit.html.haml @@ -12,8 +12,7 @@ = 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| + html: { class: 'common-note-form release-form js-quick-submit' }) 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 d1556dbd077..5bb69563b51 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -1,13 +1,9 @@ - commit_message = @page.persisted? ? s_("WikiPageEdit|Update %{page_title}") : s_("WikiPageCreate|Create %{page_title}") - commit_message = commit_message % { page_title: @page.title } -- if params[:legacy_render] || !commonmark_for_repositories_enabled? - - markdown_version = CacheMarkdownField::CACHE_REDCARPET_VERSION -- else - - markdown_version = 0 = 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: markdown_version, uploads_path: uploads_path } do |f| + data: { uploads_path: uploads_path } do |f| = form_errors(@page) - if @page.persisted? diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml index 02c5a6ea55c..28353927135 100644 --- a/app/views/projects/wikis/_sidebar.html.haml +++ b/app/views/projects/wikis/_sidebar.html.haml @@ -12,7 +12,7 @@ .blocks-container .block.block-first - if @sidebar_page - = render_wiki_content(@sidebar_page, legacy_render_context(params)) + = render_wiki_content(@sidebar_page) - else %ul.wiki-pages = render @sidebar_wiki_entries, context: 'sidebar' diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index 8b348bb4e4f..8e1c054b50c 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -27,6 +27,6 @@ .prepend-top-default.append-bottom-default .wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } - = render_wiki_content(@page, legacy_render_context(params)) + = render_wiki_content(@page) = render 'sidebar' diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml index e0130f9a4b5..a60a4501557 100644 --- a/app/views/search/results/_snippet_blob.html.haml +++ b/app/views/search/results/_snippet_blob.html.haml @@ -21,7 +21,7 @@ .file-content.wiki - snippet_chunks.each do |chunk| - unless chunk[:data].empty? - = markup(snippet.file_name, chunk[:data], legacy_render_context(params)) + = markup(snippet.file_name, chunk[:data]) - else .file-content.code .nothing-here-block= _("Empty file") diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index cb5c64fb91d..41d6ae79c81 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -54,7 +54,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, markdown_version: note.cached_markdown_version } } + .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } #{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 cf9c3055499..3007da0c189 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -3,8 +3,7 @@ .snippet-form-holder = 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| + html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" } do |f| = form_errors(@snippet) .form-group.row diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb index c8ccaf0c487..421fbf04e28 100644 --- a/app/workers/mail_scheduler/notification_service_worker.rb +++ b/app/workers/mail_scheduler/notification_service_worker.rb @@ -23,7 +23,7 @@ module MailScheduler end def self.perform_async(*args) - super(*ActiveJob::Arguments.serialize(args)) + super(*Arguments.serialize(args)) end private @@ -38,5 +38,34 @@ module MailScheduler end end end + + # Permit ActionController::Parameters for serializable Hash + # + # Port of + # https://github.com/rails/rails/commit/945fdd76925c9f615bf016717c4c8db2b2955357#diff-fc90ec41ef75be8b2259526fe1a8b663 + module Arguments + include ActiveJob::Arguments + extend self + + private + + def serialize_argument(argument) + case argument + when -> (arg) { arg.respond_to?(:permitted?) } + serialize_hash(argument.to_h).tap do |result| + result[WITH_INDIFFERENT_ACCESS_KEY] = serialize_argument(true) + end + else + super + end + end + end + + # Make sure we remove this patch starting with Rails 6.0. + if Rails.version.start_with?('6.0') + raise <<~MSG + Please remove the patch `Arguments` module and use `ActiveJob::Arguments` again. + MSG + end end end diff --git a/changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml b/changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml new file mode 100644 index 00000000000..4ce6787570a --- /dev/null +++ b/changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml @@ -0,0 +1,5 @@ +--- +title: Display timestamps to messages printed by gitlab:backup:restore rake tasks +merge_request: +author: Will Chandler +type: changed diff --git a/changelogs/unreleased/50521-block-emojis-and-symbol-characters-from-user-s-full-names-2.yml b/changelogs/unreleased/50521-block-emojis-and-symbol-characters-from-user-s-full-names-2.yml new file mode 100644 index 00000000000..04caf8262c6 --- /dev/null +++ b/changelogs/unreleased/50521-block-emojis-and-symbol-characters-from-user-s-full-names-2.yml @@ -0,0 +1,5 @@ +--- +title: Block emojis and symbol characters from users full names +merge_request: 24523 +author: +type: other diff --git a/changelogs/unreleased/51759-filter-by-language.yml b/changelogs/unreleased/51759-filter-by-language.yml new file mode 100644 index 00000000000..6b5bedd6b2d --- /dev/null +++ b/changelogs/unreleased/51759-filter-by-language.yml @@ -0,0 +1,5 @@ +--- +title: Add `with_programming_language` filter for projects to API +merge_request: 24377 +author: Dylan MacKenzie +type: added diff --git a/changelogs/unreleased/54544-update-project-topics-styling-to-use-badges-design.yml b/changelogs/unreleased/54544-update-project-topics-styling-to-use-badges-design.yml new file mode 100644 index 00000000000..de12c66e9ef --- /dev/null +++ b/changelogs/unreleased/54544-update-project-topics-styling-to-use-badges-design.yml @@ -0,0 +1,5 @@ +--- +title: Update project topics styling to use badges design +merge_request: 24415 +author: +type: changed diff --git a/changelogs/unreleased/57227-absolute-uri-missing-hierarchical-segment.yml b/changelogs/unreleased/57227-absolute-uri-missing-hierarchical-segment.yml new file mode 100644 index 00000000000..3a663ce2132 --- /dev/null +++ b/changelogs/unreleased/57227-absolute-uri-missing-hierarchical-segment.yml @@ -0,0 +1,5 @@ +--- +title: Fix potential Addressable::URI::InvalidURIError +merge_request: 24908 +author: +type: fixed diff --git a/changelogs/unreleased/diff-file-finder.yml b/changelogs/unreleased/diff-file-finder.yml new file mode 100644 index 00000000000..3160e9fc91b --- /dev/null +++ b/changelogs/unreleased/diff-file-finder.yml @@ -0,0 +1,5 @@ +--- +title: Added fuzzy file finder to merge requests +merge_request: +author: +type: changed diff --git a/changelogs/unreleased/fix_jira_integration_VCS1019.yml b/changelogs/unreleased/fix_jira_integration_VCS1019.yml new file mode 100644 index 00000000000..3582ec1fe0f --- /dev/null +++ b/changelogs/unreleased/fix_jira_integration_VCS1019.yml @@ -0,0 +1,5 @@ +--- +title: Fix Jira Service password validation on project integration services. +merge_request: 24896 +author: Daniel Juarez +type: fixed diff --git a/changelogs/unreleased/fj-regression-external-wiki-url.yml b/changelogs/unreleased/fj-regression-external-wiki-url.yml new file mode 100644 index 00000000000..d4f21dab982 --- /dev/null +++ b/changelogs/unreleased/fj-regression-external-wiki-url.yml @@ -0,0 +1,5 @@ +--- +title: Changed external wiki query method to prevent attribute caching +merge_request: 24907 +author: +type: fixed diff --git a/changelogs/unreleased/gt-externalize-app-views-email_rejection_mailer.yml b/changelogs/unreleased/gt-externalize-app-views-email_rejection_mailer.yml new file mode 100644 index 00000000000..8f6fbdceb54 --- /dev/null +++ b/changelogs/unreleased/gt-externalize-app-views-email_rejection_mailer.yml @@ -0,0 +1,5 @@ +--- +title: Externalize strings from `/app/views/email_rejection_mailer` +merge_request: 24869 +author: George Tsiolis +type: other diff --git a/changelogs/unreleased/gt-externalize-app-views-instance_statistics.yml b/changelogs/unreleased/gt-externalize-app-views-instance_statistics.yml new file mode 100644 index 00000000000..a3bf54a1339 --- /dev/null +++ b/changelogs/unreleased/gt-externalize-app-views-instance_statistics.yml @@ -0,0 +1,5 @@ +--- +title: Externalize strings from `/app/views/instance_statistics` +merge_request: 24809 +author: George Tsiolis +type: other diff --git a/changelogs/unreleased/issue_55744.yml b/changelogs/unreleased/issue_55744.yml new file mode 100644 index 00000000000..6a643732b18 --- /dev/null +++ b/changelogs/unreleased/issue_55744.yml @@ -0,0 +1,5 @@ +--- +title: Fix template labels not being created on new projects +merge_request: 24803 +author: +type: fixed diff --git a/changelogs/unreleased/jej-avoid-csrf-check-on-saml-failure.yml b/changelogs/unreleased/jej-avoid-csrf-check-on-saml-failure.yml new file mode 100644 index 00000000000..18cced2906a --- /dev/null +++ b/changelogs/unreleased/jej-avoid-csrf-check-on-saml-failure.yml @@ -0,0 +1,5 @@ +--- +title: Display SAML failure messages instead of expecting CSRF token +merge_request: 24509 +author: +type: fixed diff --git a/changelogs/unreleased/jprovazn-remove-redcarpet.yml b/changelogs/unreleased/jprovazn-remove-redcarpet.yml new file mode 100644 index 00000000000..4e12de2d19b --- /dev/null +++ b/changelogs/unreleased/jprovazn-remove-redcarpet.yml @@ -0,0 +1,5 @@ +--- +title: Removed deprecated Redcarpet markdown engine. +merge_request: +author: +type: removed diff --git a/changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml b/changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml new file mode 100644 index 00000000000..732e4baf4e9 --- /dev/null +++ b/changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml @@ -0,0 +1,5 @@ +--- +title: Don't create new merge request pipeline without commits +merge_request: 24503 +author: Hiroyuki Sato +type: added diff --git a/changelogs/unreleased/pl-serialize-ac-parameters.yml b/changelogs/unreleased/pl-serialize-ac-parameters.yml new file mode 100644 index 00000000000..aad222b5506 --- /dev/null +++ b/changelogs/unreleased/pl-serialize-ac-parameters.yml @@ -0,0 +1,5 @@ +--- +title: Make `ActionController::Parameters` serializable for sidekiq jobs +merge_request: 24864 +author: +type: fixed diff --git a/changelogs/unreleased/rd-update-last_activity_on-on-logins-and-browsing-activity-54947.yml b/changelogs/unreleased/rd-update-last_activity_on-on-logins-and-browsing-activity-54947.yml new file mode 100644 index 00000000000..abce9dcc0c6 --- /dev/null +++ b/changelogs/unreleased/rd-update-last_activity_on-on-logins-and-browsing-activity-54947.yml @@ -0,0 +1,5 @@ +--- +title: Update last_activity_on for Users on some main GET endpoints +merge_request: 24642 +author: +type: changed diff --git a/changelogs/unreleased/refactor-56370-extract-reply-placeholder-component.yml b/changelogs/unreleased/refactor-56370-extract-reply-placeholder-component.yml new file mode 100644 index 00000000000..a216d294b30 --- /dev/null +++ b/changelogs/unreleased/refactor-56370-extract-reply-placeholder-component.yml @@ -0,0 +1,5 @@ +--- +title: Extracted ReplyPlaceholder to its own component +merge_request: 24507 +author: Martin Hobert +type: other diff --git a/changelogs/unreleased/search-title.yml b/changelogs/unreleased/search-title.yml new file mode 100644 index 00000000000..ff0933ed0b2 --- /dev/null +++ b/changelogs/unreleased/search-title.yml @@ -0,0 +1,5 @@ +--- +title: Add 'in' filter that modifies scope of 'search' filter to issues and merge requests API +merge_request: 24350 +author: Hiroyuki Sato +type: added diff --git a/changelogs/unreleased/sh-encode-content-disposition.yml b/changelogs/unreleased/sh-encode-content-disposition.yml new file mode 100644 index 00000000000..b40ee6a85a8 --- /dev/null +++ b/changelogs/unreleased/sh-encode-content-disposition.yml @@ -0,0 +1,5 @@ +--- +title: Encode Content-Disposition filenames +merge_request: 24919 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-detect-host-keys.yml b/changelogs/unreleased/sh-fix-detect-host-keys.yml new file mode 100644 index 00000000000..993d7c35b18 --- /dev/null +++ b/changelogs/unreleased/sh-fix-detect-host-keys.yml @@ -0,0 +1,5 @@ +--- +title: Fix Detect Host Keys not working +merge_request: 24884 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-oauth2-callback-caps.yml b/changelogs/unreleased/sh-fix-oauth2-callback-caps.yml new file mode 100644 index 00000000000..8d17900cb79 --- /dev/null +++ b/changelogs/unreleased/sh-fix-oauth2-callback-caps.yml @@ -0,0 +1,5 @@ +--- +title: Downcase aliased OAuth2 callback providers +merge_request: 24877 +author: +type: fixed diff --git a/changelogs/unreleased/update-pages-config-only-when-changed.yml b/changelogs/unreleased/update-pages-config-only-when-changed.yml new file mode 100644 index 00000000000..8d9e02df678 --- /dev/null +++ b/changelogs/unreleased/update-pages-config-only-when-changed.yml @@ -0,0 +1,5 @@ +--- +title: Do not reload daemon if configuration file of pages does not change +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/update-pages-extensionless-urls.yml b/changelogs/unreleased/update-pages-extensionless-urls.yml new file mode 100644 index 00000000000..13b3e1df500 --- /dev/null +++ b/changelogs/unreleased/update-pages-extensionless-urls.yml @@ -0,0 +1,5 @@ +--- +title: Add support for extensionless pages URLs +merge_request: 24876 +author: +type: added diff --git a/changelogs/unreleased/update-workhorse-8-2-0.yml b/changelogs/unreleased/update-workhorse-8-2-0.yml new file mode 100644 index 00000000000..7d593917a25 --- /dev/null +++ b/changelogs/unreleased/update-workhorse-8-2-0.yml @@ -0,0 +1,5 @@ +--- +title: Update Workhorse to v8.2.0 +merge_request: 24909 +author: +type: fixed diff --git a/changelogs/unreleased/use-deployment-relation-to-fetch-environment-ce.yml b/changelogs/unreleased/use-deployment-relation-to-fetch-environment-ce.yml new file mode 100644 index 00000000000..1ec276b4abc --- /dev/null +++ b/changelogs/unreleased/use-deployment-relation-to-fetch-environment-ce.yml @@ -0,0 +1,5 @@ +--- +title: Use deployment relation to get an environment name +merge_request: 24890 +author: +type: performance diff --git a/config/initializers/sprockets_base_file_digest_key.rb b/config/initializers/sprockets_base_file_digest_key.rb new file mode 100644 index 00000000000..81ff3812091 --- /dev/null +++ b/config/initializers/sprockets_base_file_digest_key.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Sprockets::Base.prepend(Gitlab::Patch::SprocketsBaseFileDigestKey) diff --git a/config/routes/import.rb b/config/routes/import.rb index 69df82611f2..da5c31d0062 100644 --- a/config/routes/import.rb +++ b/config/routes/import.rb @@ -1,7 +1,7 @@ # Alias import callbacks under the /users/auth endpoint so that # the OAuth2 callback URL can be restricted under http://example.com/users/auth # instead of http://example.com. -Devise.omniauth_providers.each do |provider| +Devise.omniauth_providers.map(&:downcase).each do |provider| next if provider == 'ldapmain' get "/users/auth/-/import/#{provider}/callback", to: "import/#{provider}#callback", as: "users_import_#{provider}_callback" diff --git a/db/migrate/20190115092821_add_columns_project_error_tracking_settings.rb b/db/migrate/20190115092821_add_columns_project_error_tracking_settings.rb new file mode 100644 index 00000000000..190b6f958fd --- /dev/null +++ b/db/migrate/20190115092821_add_columns_project_error_tracking_settings.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddColumnsProjectErrorTrackingSettings < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :project_error_tracking_settings, :project_name, :string + add_column :project_error_tracking_settings, :organization_name, :string + + change_column_default :project_error_tracking_settings, :enabled, from: true, to: false + + change_column_null :project_error_tracking_settings, :api_url, true + end +end diff --git a/db/post_migrate/20190131122559_fix_null_type_labels.rb b/db/post_migrate/20190131122559_fix_null_type_labels.rb new file mode 100644 index 00000000000..83bb613990c --- /dev/null +++ b/db/post_migrate/20190131122559_fix_null_type_labels.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class FixNullTypeLabels < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + update_column_in_batches(:labels, :type, 'ProjectLabel') do |table, query| + query.where( + table[:project_id].not_eq(nil) + .and(table[:template].eq(false)) + .and(table[:type].eq(nil)) + ) + end + end + + def down + # no action + end +end diff --git a/db/schema.rb b/db/schema.rb index 7c1733becb9..4b6e4992056 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20190124200344) do +ActiveRecord::Schema.define(version: 20190131122559) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1574,10 +1574,12 @@ ActiveRecord::Schema.define(version: 20190124200344) do end create_table "project_error_tracking_settings", primary_key: "project_id", id: :integer, force: :cascade do |t| - t.boolean "enabled", default: true, null: false - t.string "api_url", null: false + t.boolean "enabled", default: false, null: false + t.string "api_url" t.string "encrypted_token" t.string "encrypted_token_iv" + t.string "project_name" + t.string "organization_name" end create_table "project_features", force: :cascade do |t| diff --git a/doc/api/issues.md b/doc/api/issues.md index 6d8683601f6..ed3165d95df 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -31,6 +31,7 @@ GET /issues?iids[]=42&iids[]=43 GET /issues?author_id=5 GET /issues?assignee_id=5 GET /issues?my_reaction_emoji=star +GET /issues?search=foo&in=title ``` | Attribute | Type | Required | Description | @@ -46,6 +47,7 @@ GET /issues?my_reaction_emoji=star | `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | | `search` | string | no | Search issues against their `title` and `description` | +| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joining them with comma. Default is `title,description` | | `created_after` | datetime | no | Return issues created on or after the given time | | `created_before` | datetime | no | Return issues created on or before the given time | | `updated_after` | datetime | no | Return issues updated on or after the given time | diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index b3548391228..802ff1d1df9 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -24,6 +24,7 @@ GET /merge_requests?labels=bug,reproduced GET /merge_requests?author_id=5 GET /merge_requests?my_reaction_emoji=star GET /merge_requests?scope=assigned_to_me +GET /merge_requests?search=foo&in=title ``` Parameters: @@ -47,6 +48,7 @@ Parameters: | `source_branch` | string | no | Return merge requests with the given source branch | | `target_branch` | string | no | Return merge requests with the given target branch | | `search` | string | no | Search merge requests against their `title` and `description` | +| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joining them with comma. Default is `title,description` | | `wip` | string | no | Filter merge requests against their `wip` status. `yes` to return *only* WIP merge requests, `no` to return *non* WIP merge requests | ```json diff --git a/doc/api/projects.md b/doc/api/projects.md index 1296b435792..3c0c956ddc2 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -54,6 +54,7 @@ GET /projects | `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) | | `with_issues_enabled` | boolean | no | Limit by enabled issues feature | | `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature | +| `with_programming_language` | string | no | Limit by projects which use the given programming language | | `wiki_checksum_failed` | boolean | no | Limit projects where the wiki checksum calculation has failed _([Introduced][ee-6137] in [GitLab Premium][eep] 11.2)_ | | `repository_checksum_failed` | boolean | no | Limit projects where the repository checksum calculation has failed _([Introduced][ee-6137] in [GitLab Premium][eep] 11.2)_ | | `min_access_level` | integer | no | Limit by current user minimal [access level](members.md) | @@ -279,6 +280,7 @@ GET /users/:user_id/projects | `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) | | `with_issues_enabled` | boolean | no | Limit by enabled issues feature | | `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature | +| `with_programming_language` | string | no | Limit by projects which use the given programming language | | `min_access_level` | integer | no | Limit by current user minimal [access level](members.md) | ```json diff --git a/doc/api/users.md b/doc/api/users.md index 6000b9b900f..fd8778abb17 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -1212,6 +1212,7 @@ The activities that update the timestamp are: - Git HTTP/SSH activities (such as clone, push) - User logging in into GitLab + - User visiting pages related to Dashboards, Projects, Issues and Merge Requests ([introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/54947) in GitLab 11.8) By default, it shows the activity for all users in the last 6 months, but this can be amended by using the `from` parameter. diff --git a/doc/development/sql.md b/doc/development/sql.md index 06005a0a6f8..47519d39e74 100644 --- a/doc/development/sql.md +++ b/doc/development/sql.md @@ -256,32 +256,12 @@ violation, for example. Using transactions does not solve this problem. -The following pattern should be used to avoid the problem: +To solve this we've added the `ApplicationRecord.safe_find_or_create_by`. -```ruby -Project.transaction do - begin - User.find_or_create_by(username: "foo") - rescue ActiveRecord::RecordNotUnique - retry - end -end -``` - -If the above block is run inside a transaction and hits the race -condition, the transaction is aborted and we cannot simply retry (any -further queries inside the aborted transaction are going to fail). We -can employ [nested transactions](http://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html#module-ActiveRecord::Transactions::ClassMethods-label-Nested+transactions) -here to only rollback the "inner transaction". Note that `requires_new: true` is required here. +This method can be used just as you would the normal +`find_or_create_by` but it wraps the call in a *new* transaction and +retries if it were to fail because of an +`ActiveRecord::RecordNotUnique` error. -```ruby -Project.transaction do - begin - User.transaction(requires_new: true) do - User.find_or_create_by(username: "foo") - end - rescue ActiveRecord::RecordNotUnique - retry - end -end -``` +To be able to use this method, make sure the model you want to use +this on inherits from `ApplicationRecord`. diff --git a/doc/ssh/README.md b/doc/ssh/README.md index 09a97fcea07..80de39c207a 100644 --- a/doc/ssh/README.md +++ b/doc/ssh/README.md @@ -19,7 +19,7 @@ comes pre-installed on GNU/Linux and macOS, but not on Windows. Depending on your Windows version, there are different methods to work with SSH keys. -### Installing the SSH client for Windows 10 +### Windows 10: Windows Subsystem for Linux Starting with Windows 10, you can [install the Windows Subsystem for Linux (WSL)](https://docs.microsoft.com/en-us/windows/wsl/install-win10) @@ -27,10 +27,10 @@ where you can run Linux distributions directly on Windows, without the overhead of a virtual machine. Once installed and set up, you'll have the Git and SSH clients at your disposal. -### Installing the SSH client for Windows 8.1 and Windows 7 +### Windows 10, 8.1, and 7: Git for Windows The easiest way to install Git and the SSH client on Windows 8.1 and Windows 7 -is [Git for Windows](https://gitforwindows.org). It provides a BASH +is [Git for Windows](https://gitforwindows.org). It provides a Bash emulation (Git Bash) used for running Git from the command line and the `ssh-keygen` command that is useful to create SSH keys as you'll learn below. diff --git a/doc/user/instance_statistics/user_cohorts.md b/doc/user/instance_statistics/user_cohorts.md index f52f24ef5f7..e76363a6d9f 100644 --- a/doc/user/instance_statistics/user_cohorts.md +++ b/doc/user/instance_statistics/user_cohorts.md @@ -25,3 +25,4 @@ How do we measure the activity of users? GitLab considers a user active if: - The user signs in. - The user has Git activity (whether push or pull). +- The user visits pages related to Dashboards, Projects, Issues and Merge Requests ([introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/54947) in GitLab 11.8). diff --git a/doc/user/markdown.md b/doc/user/markdown.md index f2448f240ca..9a01625f3ff 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -31,8 +31,10 @@ dependency to do so. Please see the [`github-markup` gem readme](https://github. > As of 11.1, GitLab uses the [CommonMark Ruby Library][commonmarker] for Markdown processing of all new issues, merge requests, comments, and other Markdown content in the GitLab system. As of 11.3, wiki pages and Markdown files (`.md`) in the -repositories are also processed with CommonMark. Older content in issues/comments -are still processed using the [Redcarpet Ruby library][redcarpet]. +repositories are also processed with CommonMark. As of 11.8, the [Redcarpet +Ruby library][redcarpet] has been removed and all issues/comments, including +those from pre-11.1, are now processed using [CommonMark Ruby +Library][commonmarker]. > > The documentation website had its [markdown engine migrated from Redcarpet to Kramdown](https://gitlab.com/gitlab-com/gitlab-docs/merge_requests/108) in October 2018. @@ -41,11 +43,11 @@ in October 2018. ### Transitioning to CommonMark -You may have Markdown documents in your repository that were written using some -of the nuances of RedCarpet's version of Markdown. Since CommonMark uses a -slightly stricter syntax, these documents may now display a little strangely -since we've transitioned to CommonMark. Numbered lists with nested lists in -particular can be displayed incorrectly. +You may have older issues/merge requests or Markdown documents in your +repository that were written using some of the nuances of RedCarpet's version +of Markdown. Since CommonMark uses a slightly stricter syntax, these documents +may now display a little strangely since we've transitioned to CommonMark. +Numbered lists with nested lists in particular can be displayed incorrectly. It is usually quite easy to fix. In the case of a nested list such as this: @@ -65,11 +67,6 @@ simply add a space to each nested item: In the documentation below, we try to highlight some of the differences. -If you have a need to view a document using RedCarpet, you can add the token -`legacy_render=1` to the end of the url, like this: - -https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md?legacy_render=1 - If you have a large volume of Markdown files, it can be tedious to determine if they will be displayed correctly or not. You can use the [diff_redcarpet_cmark](https://gitlab.com/digitalmoksha/diff_redcarpet_cmark) @@ -677,7 +674,7 @@ Becomes: + Or pluses If a list item contains multiple paragraphs, -each subsequent paragraph should be indented to the same level as the start of the list item text (_Redcarpet: paragraph should be indented with four spaces._) +each subsequent paragraph should be indented to the same level as the start of the list item text Example: @@ -841,7 +838,7 @@ These details <em>will</em> remain <strong>hidden</strong> until expanded. </details> </p> -**Note:** Markdown inside these tags is supported, as long as you have a blank line after the `</summary>` tag and before the `</details>` tag, as shown in the example. _Redcarpet does not support Markdown inside these tags. You can work around this by using HTML, for example you can use `<pre><code>` tags instead of [code fences](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#code-and-syntax-highlighting)._ +**Note:** Markdown inside these tags is supported, as long as you have a blank line after the `</summary>` tag and before the `</details>` tag, as shown in the example. ```html <details> diff --git a/doc/user/project/operations/index.md b/doc/user/project/operations/index.md new file mode 100644 index 00000000000..b0f9936be5c --- /dev/null +++ b/doc/user/project/operations/index.md @@ -0,0 +1,11 @@ +# Project operations + +GitLab provides a variety of tools to help operate and maintain +your applications: + +- Collect [Prometheus metrics](../integrations/prometheus_library/index.md). +- Deploy to different [environments](../../../ci/environments.md). +- Connect your project to a [Kubernetes cluster](../clusters/index.md). +- Discover and view errors generated by your applications with [Error Tracking](error_tracking.md). +- Create, toggle, and remove [Feature Flags](https://docs.gitlab.com/ee/user/project/operations/feature_flags.html). **[PREMIUM]** +- [Trace](https://docs.gitlab.com/ee/user/project/operations/tracing.html) the performance and health of a deployed application. **[ULTIMATE]** diff --git a/doc/user/project/pages/getting_started_part_two.md b/doc/user/project/pages/getting_started_part_two.md index b0560c2f44c..8dbbdd051f1 100644 --- a/doc/user/project/pages/getting_started_part_two.md +++ b/doc/user/project/pages/getting_started_part_two.md @@ -31,12 +31,26 @@ The optional settings, custom domain, DNS records, and SSL/TLS certificates, are ## Project Your GitLab Pages project is a regular project created the -same way you do for the other ones. To get started with GitLab Pages, you have two ways: +same way you do for the other ones. To get started with GitLab Pages, you have three ways: +- Use one of the popular templates already in the app, - Fork one of the templates from Page Examples, or - Create a new project from scratch -Let's go over both options. +Let's go over each option. + +### Use one of the popular Pages templates bundled with GitLab + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/47857) +in GitLab 11.8. + +The simplest way to create a GitLab Pages site is to use one of the most +popular templates, which come already bundled and ready to go. To use one +of these templates: + +1. From the top navigation, click the **+** button and select **New project** +1. Select **Create from Template** +1. Choose one of the templates starting with **Pages** ### Fork a project to get started from diff --git a/doc/user/project/pages/index.md b/doc/user/project/pages/index.md index ce4fccdaff3..11f6165fcb4 100644 --- a/doc/user/project/pages/index.md +++ b/doc/user/project/pages/index.md @@ -91,8 +91,8 @@ site under the HTTPS protocol. ## Getting started -To get started with GitLab Pages, you can either [create a project from scratch](getting_started_part_two.md#create-a-project-from-scratch) -or quickly start from copying an existing example project, as follows: +To get started with GitLab Pages, you can either [create a project from scratch](getting_started_part_two.md#create-a-project-from-scratch), +use a [bundled template](getting_started_part_two.md#use-one-of-the-popular-pages-templates-bundled-with-gitlab), or copy any of our existing example projects: 1. Choose an [example project](https://gitlab.com/pages) to [fork](../../../gitlab-basics/fork-project.md#how-to-fork-a-project): by forking a project, you create a copy of the codebase you're forking from to start from a template instead of starting from scratch. diff --git a/doc/user/project/pages/introduction.md b/doc/user/project/pages/introduction.md index 2bb6fcd9d74..cebff38ba88 100644 --- a/doc/user/project/pages/introduction.md +++ b/doc/user/project/pages/introduction.md @@ -356,6 +356,57 @@ By pre-compressing the files and including both versions in the artifact, Pages can serve requests for both compressed and uncompressed content without needing to compress files on-demand. +### Resolving ambiguous URLs + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-pages/issues/95) in GitLab 11.8 + +GitLab Pages makes assumptions about which files to serve when receiving a +request for a URL that does not include an extension. + +Consider a Pages site deployed with the following files: + +``` +public/ +├─┬ index.html +│ ├ data.html +│ └ info.html +│ +├── data/ +│ └── index.html +├── info/ +│ └── details.html +└── other/ + └── index.html +``` + +Pages supports reaching each of these files through several different URLs. In +particular, it will always look for an `index.html` file if the URL only +specifies the directory. If the URL references a file that doesn't exist, but +adding `.html` to the URL leads to a file that *does* exist, it will be served +instead. Here are some examples of what will happen given the above Pages site: + +| URL path | HTTP response | File served | +| -------------------- | ------------- | ----------- | +| `/` | `200 OK` | `public/index.html` | +| `/index.html` | `200 OK` | `public/index.html` | +| `/index` | `200 OK` | `public/index.html` | +| `/data` | `200 OK` | `public/data/index.html` | +| `/data/` | `200 OK` | `public/data/index.html` | +| `/data.html` | `200 OK` | `public/data.html` | +| `/info` | `200 OK` | `public/info.html` | +| `/info/` | `200 OK` | `public/info.html` | +| `/info.html` | `200 OK` | `public/info.html` | +| `/info/details` | `200 OK` | `public/info/details.html` | +| `/info/details.html` | `200 OK` | `public/info/details.html` | +| `/other` | `302 Found` | `public/other/index.html` | +| `/other/` | `200 OK` | `public/other/index.html` | +| `/other/index` | `200 OK` | `public/other/index.html` | +| `/other/index.html` | `200 OK` | `public/other/index.html` | + +NOTE: **Note:** +When `public/data/index.html` exists, it takes priority over the `public/data.html` +file for both the `/data` and `/data/` URL paths. + ### Add a custom domain to your Pages website For a complete guide on Pages domains, read through the article diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index fa6c9777824..e3d0b981065 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -422,7 +422,7 @@ module API def present_disk_file!(path, filename, content_type = 'application/octet-stream') filename ||= File.basename(path) - header['Content-Disposition'] = "attachment; filename=#{filename}" + header['Content-Disposition'] = ::Gitlab::ContentDisposition.format(disposition: 'attachment', filename: filename) header['Content-Transfer-Encoding'] = 'binary' content_type content_type @@ -496,7 +496,7 @@ module API def send_git_blob(repository, blob) env['api.format'] = :txt content_type 'text/plain' - header['Content-Disposition'] = content_disposition('inline', blob.name) + header['Content-Disposition'] = ::Gitlab::ContentDisposition.format(disposition: 'inline', filename: blob.name) # Let Workhorse examine the content and determine the better content disposition header[Gitlab::Workhorse::DETECT_HEADER] = "true" @@ -533,11 +533,5 @@ module API params[:archived] end - - def content_disposition(disposition, filename) - disposition += %(; filename=#{filename.inspect}) if filename.present? - - disposition - end end end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index afa3ac80121..b3636c98550 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -43,7 +43,8 @@ module API desc: 'Return issues sorted in `asc` or `desc` order.' optional :milestone, type: String, desc: 'Return issues for a specific milestone' optional :iids, type: Array[Integer], desc: 'The IID array of issues' - optional :search, type: String, desc: 'Search issues for text present in the title or description' + optional :search, type: String, desc: 'Search issues for text present in the title, description, or any combination of these' + optional :in, type: String, desc: '`title`, `description`, or a string joining them with comma' optional :created_after, type: DateTime, desc: 'Return issues created after the specified time' optional :created_before, type: DateTime, desc: 'Return issues created before the specified time' optional :updated_after, type: DateTime, desc: 'Return issues updated after the specified time' diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 132b19164d0..4179aaa93a0 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -109,7 +109,8 @@ module API optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji' optional :source_branch, type: String, desc: 'Return merge requests with the given source branch' optional :target_branch, type: String, desc: 'Return merge requests with the given target branch' - optional :search, type: String, desc: 'Search merge requests for text present in the title or description' + optional :search, type: String, desc: 'Search merge requests for text present in the title, description, or any combination of these' + optional :in, type: String, desc: '`title`, `description`, or a string joining them with comma' optional :wip, type: String, values: %w[yes no], desc: 'Search merge requests for WIP in the title' use :pagination end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 3afa2d8a6b0..6a93ef9f3ad 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -25,6 +25,9 @@ module API projects = projects.with_merge_requests_enabled if params[:with_merge_requests_enabled] projects = projects.with_statistics if params[:statistics] + lang = params[:with_programming_language] + projects = projects.with_programming_language(lang) if lang + projects end @@ -91,6 +94,7 @@ module API optional :membership, type: Boolean, default: false, desc: 'Limit by projects that the current user is a member of' optional :with_issues_enabled, type: Boolean, default: false, desc: 'Limit by enabled issues feature' optional :with_merge_requests_enabled, type: Boolean, default: false, desc: 'Limit by enabled merge requests feature' + optional :with_programming_language, type: String, desc: 'Limit to repositories which use the given programming language' optional :min_access_level, type: Integer, values: Gitlab::Access.all_values, desc: 'Limit by minimum access level of authenticated user' use :optional_filter_params_ee diff --git a/lib/banzai/filter/autolink_filter.rb b/lib/banzai/filter/autolink_filter.rb index f3061bad4ff..086adf59d2b 100644 --- a/lib/banzai/filter/autolink_filter.rb +++ b/lib/banzai/filter/autolink_filter.rb @@ -114,7 +114,11 @@ module Banzai # Since this came from a Text node, make sure the new href is encoded. # `commonmarker` percent encodes the domains of links it handles, so # do the same (instead of using `normalized_encode`). - href_safe = Addressable::URI.encode(match).html_safe + begin + href_safe = Addressable::URI.encode(match).html_safe + rescue Addressable::URI::InvalidURIError + return uri.to_s + end html_safe_match = match.html_safe options = link_options.merge(href: href_safe) diff --git a/lib/banzai/filter/markdown_engines/redcarpet.rb b/lib/banzai/filter/markdown_engines/redcarpet.rb deleted file mode 100644 index 5b3f75096b1..00000000000 --- a/lib/banzai/filter/markdown_engines/redcarpet.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -# `Redcarpet` markdown engine for GitLab's Banzai markdown filter. -# This module is used in Banzai::Filter::MarkdownFilter. -# Used gem is `redcarpet` which is a ruby library for markdown processing. -# Homepage: https://github.com/vmg/redcarpet - -module Banzai - module Filter - module MarkdownEngines - class Redcarpet - OPTIONS = { - fenced_code_blocks: true, - footnotes: true, - lax_spacing: true, - no_intra_emphasis: true, - space_after_headers: true, - strikethrough: true, - superscript: true, - tables: true - }.freeze - - def initialize(context = nil) - html_renderer = Banzai::Renderer::Redcarpet::HTML.new - @renderer = ::Redcarpet::Markdown.new(html_renderer, OPTIONS) - end - - def render(text) - @renderer.render(text) - end - end - end - end -end diff --git a/lib/banzai/filter/spaced_link_filter.rb b/lib/banzai/filter/spaced_link_filter.rb index 00dbf2d3130..50bf823929c 100644 --- a/lib/banzai/filter/spaced_link_filter.rb +++ b/lib/banzai/filter/spaced_link_filter.rb @@ -45,8 +45,6 @@ module Banzai ]).freeze def call - return doc if context[:markdown_engine] == :redcarpet - doc.xpath(TEXT_QUERY).each do |node| content = node.to_html diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb index bcf77861f10..9ffde52b5f2 100644 --- a/lib/banzai/filter/syntax_highlight_filter.rb +++ b/lib/banzai/filter/syntax_highlight_filter.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'rouge/plugins/common_mark' -require 'rouge/plugins/redcarpet' # Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/code_block.js module Banzai diff --git a/lib/banzai/renderer/redcarpet/html.rb b/lib/banzai/renderer/redcarpet/html.rb deleted file mode 100644 index 84931fdc784..00000000000 --- a/lib/banzai/renderer/redcarpet/html.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Renderer - module Redcarpet - class HTML < ::Redcarpet::Render::HTML - def block_code(code, lang) - lang_attr = lang ? %Q{ lang="#{lang}"} : '' - - "\n<pre>" \ - "<code#{lang_attr}>#{ERB::Util.html_escape(code)}</code>" \ - "</pre>" - end - end - end - end -end diff --git a/lib/gitlab/background_migration.rb b/lib/gitlab/background_migration.rb index 6cf40e2d4ca..5251e0fadf9 100644 --- a/lib/gitlab/background_migration.rb +++ b/lib/gitlab/background_migration.rb @@ -16,11 +16,18 @@ module Gitlab # re-raises the exception. # # steal_class - The name of the class for which to steal jobs. - def self.steal(steal_class) - enqueued = Sidekiq::Queue.new(self.queue) - scheduled = Sidekiq::ScheduledSet.new + def self.steal(steal_class, retry_dead_jobs: false) + queues = [ + Sidekiq::ScheduledSet.new, + Sidekiq::Queue.new(self.queue) + ] + + if retry_dead_jobs + queues << Sidekiq::RetrySet.new + queues << Sidekiq::DeadSet.new + end - [scheduled, enqueued].each do |queue| + queues.each do |queue| queue.each do |job| migration_class, migration_args = job.args diff --git a/lib/gitlab/ci/pipeline/chain/limit/activity.rb b/lib/gitlab/ci/pipeline/chain/limit/activity.rb new file mode 100644 index 00000000000..fe7c8738cc0 --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/limit/activity.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + module Limit + class Activity < Chain::Base + def perform! + # to be overriden in EE + end + + def break? + false # to be overriden in EE + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/chain/limit/size.rb b/lib/gitlab/ci/pipeline/chain/limit/size.rb new file mode 100644 index 00000000000..b4d51437cd6 --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/limit/size.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + module Limit + class Size < Chain::Base + def perform! + # to be overriden in EE + end + + def break? + false # to be overriden in EE + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb b/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb new file mode 100644 index 00000000000..0f687a4ce9b --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + class RemoveUnwantedChatJobs < Chain::Base + def perform! + # to be overriden in EE + end + + def break? + false + end + end + end + end + end +end diff --git a/lib/gitlab/content_disposition.rb b/lib/gitlab/content_disposition.rb new file mode 100644 index 00000000000..32207514ce5 --- /dev/null +++ b/lib/gitlab/content_disposition.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true +# This ports ActionDispatch::Http::ContentDisposition (https://github.com/rails/rails/pull/33829, +# which will be available in Rails 6. +module Gitlab + class ContentDisposition # :nodoc: + # Make sure we remove this patch starting with Rails 6.0. + if Rails.version.start_with?('6.0') + raise <<~MSG + Please remove this file and use `ActionDispatch::Http::ContentDisposition` instead. + MSG + end + + def self.format(disposition:, filename:) + new(disposition: disposition, filename: filename).to_s + end + + attr_reader :disposition, :filename + + def initialize(disposition:, filename:) + @disposition = disposition + @filename = filename + end + + # rubocop:disable Style/VariableInterpolation + TRADITIONAL_ESCAPED_CHAR = /[^ A-Za-z0-9!#$+.^_`|~-]/ + + def ascii_filename + 'filename="' + percent_escape(::I18n.transliterate(filename), TRADITIONAL_ESCAPED_CHAR) + '"' + end + + RFC_5987_ESCAPED_CHAR = /[^A-Za-z0-9!#$&+.^_`|~-]/ + # rubocop:enable Style/VariableInterpolation + + def utf8_filename + "filename*=UTF-8''" + percent_escape(filename, RFC_5987_ESCAPED_CHAR) + end + + def to_s + if filename + "#{disposition}; #{ascii_filename}; #{utf8_filename}" + else + "#{disposition}" + end + end + + private + + def percent_escape(string, pattern) + string.gsub(pattern) do |char| + char.bytes.map { |byte| "%%%02X" % byte }.join + end + end + end +end diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 7987533978c..add7ee58da6 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -166,6 +166,7 @@ excluded_attributes: error_tracking_setting: - :encrypted_token - :encrypted_token_iv + - :enabled methods: labels: diff --git a/lib/gitlab/patch/sprockets_base_file_digest_key.rb b/lib/gitlab/patch/sprockets_base_file_digest_key.rb new file mode 100644 index 00000000000..3925cdbbada --- /dev/null +++ b/lib/gitlab/patch/sprockets_base_file_digest_key.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# This monkey patch prevent cache ballooning when caching tmp/cache/assets/sprockets +# on the CI. See https://github.com/rails/sprockets/issues/563 and +# https://github.com/rails/sprockets/compare/3.x...jmreid:no-mtime-for-digest-key. +module Gitlab + module Patch + module SprocketsBaseFileDigestKey + def file_digest(path) + if stat = self.stat(path) + digest = self.stat_digest(path, stat) + integrity_uri = self.hexdigest_integrity_uri(digest) + + key = Sprockets::UnloadedAsset.new(path, self).file_digest_key(integrity_uri) + cache.fetch(key) do + digest + end + end + end + end + end +end diff --git a/lib/gitlab/task_helpers.rb b/lib/gitlab/task_helpers.rb index 224bb648d8f..8532845f3cb 100644 --- a/lib/gitlab/task_helpers.rb +++ b/lib/gitlab/task_helpers.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'rainbow/ext/string' -require 'gitlab/utils/strong_memoize' +require_dependency 'gitlab/utils/strong_memoize' # rubocop:disable Rails/Output module Gitlab @@ -13,6 +13,12 @@ module Gitlab extend self + def invoke_and_time_task(task) + start = Time.now + Rake::Task[task].invoke + puts "`#{task}` finished in #{Time.now - start} seconds" + end + # Ask if the user wants to continue # # Returns "yes" the user chose to continue diff --git a/lib/gitlab/utils/merge_hash.rb b/lib/gitlab/utils/merge_hash.rb index fc237861e2f..48ba13b8561 100644 --- a/lib/gitlab/utils/merge_hash.rb +++ b/lib/gitlab/utils/merge_hash.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_dependency 'gitlab/utils' + module Gitlab module Utils module MergeHash diff --git a/lib/gitlab/utils/override.rb b/lib/gitlab/utils/override.rb index c87e97d0213..f5299439fce 100644 --- a/lib/gitlab/utils/override.rb +++ b/lib/gitlab/utils/override.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_dependency 'gitlab/utils' + module Gitlab module Utils module Override diff --git a/lib/gitlab/utils/strong_memoize.rb b/lib/gitlab/utils/strong_memoize.rb index aa1f8e2fdda..3021a91dd83 100644 --- a/lib/gitlab/utils/strong_memoize.rb +++ b/lib/gitlab/utils/strong_memoize.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_dependency 'gitlab/utils' + module Gitlab module Utils module StrongMemoize diff --git a/lib/tasks/gitlab/assets.rake b/lib/tasks/gitlab/assets.rake index a42f02a84fd..7a42e4e92a0 100644 --- a/lib/tasks/gitlab/assets.rake +++ b/lib/tasks/gitlab/assets.rake @@ -1,13 +1,17 @@ namespace :gitlab do namespace :assets do desc 'GitLab | Assets | Compile all frontend assets' - task compile: [ - 'yarn:check', - 'gettext:po_to_json', - 'rake:assets:precompile', - 'webpack:compile', - 'fix_urls' - ] + task :compile do + require_dependency 'gitlab/task_helpers' + + %w[ + yarn:check + gettext:po_to_json + rake:assets:precompile + webpack:compile + gitlab:assets:fix_urls + ].each(&Gitlab::TaskHelpers.method(:invoke_and_time_task)) + end desc 'GitLab | Assets | Clean up old compiled frontend assets' task clean: ['rake:assets:clean'] diff --git a/lib/tasks/gitlab/backup.rake b/lib/tasks/gitlab/backup.rake index e96fbb64372..3a1a36e36ce 100644 --- a/lib/tasks/gitlab/backup.rake +++ b/lib/tasks/gitlab/backup.rake @@ -47,9 +47,9 @@ namespace :gitlab do # Drop all tables Load the schema to ensure we don't have any newer tables # hanging out from a failed upgrade - progress.puts 'Cleaning the database ... '.color(:blue) + puts_time 'Cleaning the database ... '.color(:blue) Rake::Task['gitlab:db:drop_tables'].invoke - progress.puts 'done'.color(:green) + puts_time 'done'.color(:green) Rake::Task['gitlab:backup:db:restore'].invoke rescue Gitlab::TaskAbortedByUserError puts "Quitting...".color(:red) @@ -72,165 +72,169 @@ namespace :gitlab do namespace :repo do task create: :gitlab_environment do - progress.puts "Dumping repositories ...".color(:blue) + puts_time "Dumping repositories ...".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("repositories") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Repository.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring repositories ...".color(:blue) + puts_time "Restoring repositories ...".color(:blue) Backup::Repository.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :db do task create: :gitlab_environment do - progress.puts "Dumping database ... ".color(:blue) + puts_time "Dumping database ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("db") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Database.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring database ... ".color(:blue) + puts_time "Restoring database ... ".color(:blue) Backup::Database.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :builds do task create: :gitlab_environment do - progress.puts "Dumping builds ... ".color(:blue) + puts_time "Dumping builds ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("builds") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Builds.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring builds ... ".color(:blue) + puts_time "Restoring builds ... ".color(:blue) Backup::Builds.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :uploads do task create: :gitlab_environment do - progress.puts "Dumping uploads ... ".color(:blue) + puts_time "Dumping uploads ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("uploads") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Uploads.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring uploads ... ".color(:blue) + puts_time "Restoring uploads ... ".color(:blue) Backup::Uploads.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :artifacts do task create: :gitlab_environment do - progress.puts "Dumping artifacts ... ".color(:blue) + puts_time "Dumping artifacts ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("artifacts") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Artifacts.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring artifacts ... ".color(:blue) + puts_time "Restoring artifacts ... ".color(:blue) Backup::Artifacts.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :pages do task create: :gitlab_environment do - progress.puts "Dumping pages ... ".color(:blue) + puts_time "Dumping pages ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("pages") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Pages.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring pages ... ".color(:blue) + puts_time "Restoring pages ... ".color(:blue) Backup::Pages.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :lfs do task create: :gitlab_environment do - progress.puts "Dumping lfs objects ... ".color(:blue) + puts_time "Dumping lfs objects ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("lfs") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Lfs.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring lfs objects ... ".color(:blue) + puts_time "Restoring lfs objects ... ".color(:blue) Backup::Lfs.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :registry do task create: :gitlab_environment do - progress.puts "Dumping container registry images ... ".color(:blue) + puts_time "Dumping container registry images ... ".color(:blue) if Gitlab.config.registry.enabled if ENV["SKIP"] && ENV["SKIP"].include?("registry") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Registry.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end else - progress.puts "[DISABLED]".color(:cyan) + puts_time "[DISABLED]".color(:cyan) end end task restore: :gitlab_environment do - progress.puts "Restoring container registry images ... ".color(:blue) + puts_time "Restoring container registry images ... ".color(:blue) if Gitlab.config.registry.enabled Backup::Registry.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) else - progress.puts "[DISABLED]".color(:cyan) + puts_time "[DISABLED]".color(:cyan) end end end + def puts_time(msg) + progress.puts "#{Time.now} -- #{msg}" + end + def progress if ENV['CRON'] # We need an object we can say 'puts' and 'print' to; let's use a diff --git a/locale/gitlab.pot b/locale/gitlab.pot index fd7e4754a7a..6687cdd5feb 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -552,9 +552,6 @@ msgstr "" msgid "An empty GitLab User field will add the FogBugz user's full name (e.g. \"By John Smith\") in the description of all issues and comments. It will also associate and/or assign these issues and comments with the project creator." msgstr "" -msgid "An error accured whilst committing your changes." -msgstr "" - msgid "An error has occurred" msgstr "" @@ -639,6 +636,9 @@ msgstr "" msgid "An error occurred while validating username" msgstr "" +msgid "An error occurred whilst committing your changes." +msgstr "" + msgid "An error occurred whilst fetching the job trace." msgstr "" @@ -2426,6 +2426,9 @@ msgstr "" msgid "DashboardProjects|Personal" msgstr "" +msgid "Data is still calculating..." +msgstr "" + msgid "Debug" msgstr "" @@ -2707,6 +2710,9 @@ msgstr "" msgid "Dismiss" msgstr "" +msgid "Dismiss ConvDev introduction" +msgstr "" + msgid "Dismiss Cycle Analytics introduction box" msgstr "" @@ -3837,6 +3843,9 @@ msgstr "" msgid "In order to enable instance-level analytics, please ask an admin to enable %{usage_ping_link_start}usage ping%{usage_ping_link_end}." msgstr "" +msgid "In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index." +msgstr "" + msgid "In the next step, you'll be able to select the projects you want to import." msgstr "" @@ -3906,6 +3915,9 @@ msgstr "" msgid "Introducing Cycle Analytics" msgstr "" +msgid "Introducing Your Conversational Development Index" +msgstr "" + msgid "Invitation" msgstr "" @@ -4145,6 +4157,9 @@ msgstr "" msgid "Latest pipeline for this branch" msgstr "" +msgid "Lead" +msgstr "" + msgid "Learn more" msgstr "" @@ -4378,9 +4393,15 @@ msgstr "" msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others" msgstr "" +msgid "MergeRequests|Add a reply" +msgstr "" + msgid "MergeRequests|Jump to next unresolved discussion" msgstr "" +msgid "MergeRequests|Reply..." +msgstr "" + msgid "MergeRequests|Resolve this discussion in a new issue" msgstr "" @@ -4417,10 +4438,10 @@ msgstr "" msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}" msgstr "" -msgid "MergeRequest|Filter files" +msgid "MergeRequest|No files found" msgstr "" -msgid "MergeRequest|No files found" +msgid "MergeRequest|Search files" msgstr "" msgid "Merged" @@ -5519,6 +5540,9 @@ msgstr "" msgid "Profiles|Username successfully changed" msgstr "" +msgid "Profiles|Using emojis in names seems fun, but please try to set a status message instead" +msgstr "" + msgid "Profiles|What's your status?" msgstr "" @@ -7659,6 +7683,9 @@ msgstr "" msgid "Undo" msgstr "" +msgid "Unfortunately, your email message to GitLab could not be processed." +msgstr "" + msgid "Unlock" msgstr "" @@ -8136,6 +8163,9 @@ msgstr "" msgid "Yesterday" msgstr "" +msgid "You" +msgstr "" + msgid "You are an admin, which means granting access to <strong>%{client_name}</strong> will allow them to interact with GitLab as an admin as well. Proceed with caution." msgstr "" @@ -8274,6 +8304,9 @@ msgstr "" msgid "YouTube" msgstr "" +msgid "Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers." +msgstr "" + msgid "Your Groups" msgstr "" @@ -8440,6 +8473,9 @@ msgstr "" msgid "in project %{link_to_project}" msgstr "" +msgid "index" +msgstr "" + msgid "issue boards" msgstr "" @@ -8679,6 +8715,15 @@ msgstr "" msgid "notification emails" msgstr "" +msgid "nounSeries|%{firstItem} and %{lastItem}" +msgstr "" + +msgid "nounSeries|%{item}, %{nextItem}" +msgstr "" + +msgid "nounSeries|%{item}, and %{lastItem}" +msgstr "" + msgid "or" msgstr "" @@ -8724,6 +8769,9 @@ msgid_plural "replies" msgstr[0] "" msgstr[1] "" +msgid "score" +msgstr "" + msgid "should be higher than %{access} inherited membership from group %{group_name}" msgstr "" diff --git a/package.json b/package.json index bd673b86fab..97d8fd3b17f 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@babel/preset-env": "^7.3.1", "@gitlab/csslab": "^1.8.0", "@gitlab/svgs": "^1.48.0", - "@gitlab/ui": "^1.22.1", + "@gitlab/ui": "^2.0.2", "apollo-boost": "^0.1.20", "apollo-client": "^2.4.5", "autosize": "^4.0.0", @@ -58,9 +58,10 @@ "dateformat": "^3.0.3", "deckar01-task_list": "^2.2.0", "diff": "^3.4.0", - "document-register-element": "1.3.0", + "document-register-element": "1.13.1", "dropzone": "^4.2.0", "echarts": "^4.2.0-rc.2", + "emoji-regex": "^7.0.3", "emoji-unicode-version": "^0.2.1", "exports-loader": "^0.7.0", "file-loader": "^3.0.1", diff --git a/scripts/clean-old-cached-assets b/scripts/clean-old-cached-assets new file mode 100755 index 00000000000..7a3a62a477a --- /dev/null +++ b/scripts/clean-old-cached-assets @@ -0,0 +1,6 @@ +#!/bin/bash + +# Clean up cached files that are older than 1 week +find tmp/cache/assets/sprockets/ -type f -mtime +7 -execdir rm -- "{}" \; + +du -d 0 -h tmp/cache/assets/sprockets | cut -f1 | xargs -I % echo "tmp/cache/assets/sprockets/ is currently %" diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh index 6e0dee9e090..f610485a700 100755 --- a/scripts/review_apps/review-apps.sh +++ b/scripts/review_apps/review-apps.sh @@ -143,11 +143,16 @@ HELM_CMD=$(cat << EOF --set global.hosts.hostSuffix="$HOST_SUFFIX" \ --set global.hosts.domain="$REVIEW_APPS_DOMAIN" \ --set certmanager.install=false \ + --set prometheus.install=false \ --set global.ingress.configureCertmanager=false \ --set global.ingress.tls.secretName=tls-cert \ --set global.ingress.annotations."external-dns\.alpha\.kubernetes\.io/ttl"="10" + --set nginx-ingress.defaultBackend.resources.requests.memory=7Mi \ + --set nginx-ingress.controller.resources.requests.memory=440M \ + --set nginx-ingress.controller.replicaCount=2 \ --set gitlab.unicorn.resources.requests.cpu=200m \ --set gitlab.sidekiq.resources.requests.cpu=100m \ + --set gitlab.sidekiq.resources.requests.memory=800M \ --set gitlab.gitlab-shell.resources.requests.cpu=100m \ --set redis.resources.requests.cpu=100m \ --set minio.resources.requests.cpu=100m \ diff --git a/spec/controllers/concerns/send_file_upload_spec.rb b/spec/controllers/concerns/send_file_upload_spec.rb index 379b2d6b935..a07113a6156 100644 --- a/spec/controllers/concerns/send_file_upload_spec.rb +++ b/spec/controllers/concerns/send_file_upload_spec.rb @@ -53,19 +53,38 @@ describe SendFileUpload do end context 'with attachment' do - let(:params) { { attachment: 'test.js' } } + let(:filename) { 'test.js' } + let(:params) { { attachment: filename } } it 'sends a file with content-type of text/plain' do + # Notice the filename= is omitted from the disposition; this is because + # Rails 5 will append this header in send_file expected_params = { content_type: 'text/plain', filename: 'test.js', - disposition: 'attachment' + disposition: "attachment; filename*=UTF-8''test.js" } expect(controller).to receive(:send_file).with(uploader.path, expected_params) subject end + context 'with non-ASCII encoded filename' do + let(:filename) { 'テスト.txt' } + + # Notice the filename= is omitted from the disposition; this is because + # Rails 5 will append this header in send_file + it 'sends content-disposition for non-ASCII encoded filenames' do + expected_params = { + filename: filename, + disposition: "attachment; filename*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88.txt" + } + expect(controller).to receive(:send_file).with(uploader.path, expected_params) + + subject + end + end + context 'with a proxied file in object storage' do before do stub_uploads_object_storage(uploader: uploader_class) @@ -76,7 +95,7 @@ describe SendFileUpload do it 'sends a file with a custom type' do headers = double - expected_headers = %r(response-content-disposition=attachment%3Bfilename%3D%22test.js%22&response-content-type=application/ecmascript) + expected_headers = %r(response-content-disposition=attachment%3B%20filename%3D%22test.js%22%3B%20filename%2A%3DUTF-8%27%27test.js&response-content-type=application/ecmascript) expect(Gitlab::Workhorse).to receive(:send_url).with(expected_headers).and_call_original expect(headers).to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-url:/) diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb index 59463462e5a..232a5e2793b 100644 --- a/spec/controllers/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/omniauth_callbacks_controller_spec.rb @@ -45,6 +45,29 @@ describe OmniauthCallbacksController, type: :controller do end end + context 'when sign in fails' do + include RoutesHelpers + + let(:extern_uid) { 'my-uid' } + let(:provider) { :saml } + + def stub_route_as(path) + allow(@routes).to receive(:generate_extras) { [path, []] } + end + + it 'it calls through to the failure handler' do + request.env['omniauth.error'] = OneLogin::RubySaml::ValidationError.new("Fingerprint mismatch") + request.env['omniauth.error.strategy'] = OmniAuth::Strategies::SAML.new(nil) + stub_route_as('/users/auth/saml/callback') + + ForgeryProtection.with_forgery_protection do + post :failure + end + + expect(flash[:alert]).to match(/Fingerprint mismatch/) + end + end + context 'when a redirect fragment is provided' do let(:provider) { :jwt } let(:extern_uid) { 'my-uid' } diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb index bd10de45b67..29df00e6bb0 100644 --- a/spec/controllers/projects/artifacts_controller_spec.rb +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -26,8 +26,15 @@ describe Projects::ArtifactsController do end context 'when no file type is supplied' do + let(:filename) { job.artifacts_file.filename } + it 'sends the artifacts file' do - expect(controller).to receive(:send_file).with(job.artifacts_file.path, hash_including(disposition: 'attachment')).and_call_original + # Notice the filename= is omitted from the disposition; this is because + # Rails 5 will append this header in send_file + expect(controller).to receive(:send_file) + .with( + job.artifacts_file.file.path, + hash_including(disposition: %Q(attachment; filename*=UTF-8''#{filename}))).and_call_original download_artifact end @@ -46,6 +53,7 @@ describe Projects::ArtifactsController do context 'when codequality file type is supplied' do let(:file_type) { 'codequality' } + let(:filename) { job.job_artifacts_codequality.filename } context 'when file is stored locally' do before do @@ -53,7 +61,11 @@ describe Projects::ArtifactsController do end it 'sends the codequality report' do - expect(controller).to receive(:send_file).with(job.job_artifacts_codequality.file.path, hash_including(disposition: 'attachment')).and_call_original + # Notice the filename= is omitted from the disposition; this is because + # Rails 5 will append this header in send_file + expect(controller).to receive(:send_file) + .with(job.job_artifacts_codequality.file.path, + hash_including(disposition: %Q(attachment; filename*=UTF-8''#{filename}))).and_call_original download_artifact(file_type: file_type) end diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb index 4a5d2bdecb7..601a292bf54 100644 --- a/spec/controllers/projects/services_controller_spec.rb +++ b/spec/controllers/projects/services_controller_spec.rb @@ -152,6 +152,16 @@ describe Projects::ServicesController do expect(service.namespace).not_to eq('updated_namespace') end end + + context 'when activating JIRA service from a template' do + let(:template_service) { create(:jira_service, project: project, template: true) } + + it 'activate JIRA service from template' do + put :update, params: { namespace_id: project.namespace, project_id: project, id: service.to_param, service: { active: true } } + + expect(flash[:notice]).to eq 'JIRA activated.' + end + end end describe "GET #edit" do diff --git a/spec/factories/project_error_tracking_settings.rb b/spec/factories/project_error_tracking_settings.rb index fbd8dfd395c..be30bd0260a 100644 --- a/spec/factories/project_error_tracking_settings.rb +++ b/spec/factories/project_error_tracking_settings.rb @@ -6,5 +6,7 @@ FactoryBot.define do api_url 'https://gitlab.com/api/0/projects/sentry-org/sentry-project' enabled true token 'access_token_123' + project_name 'Sentry Project' + organization_name 'Sentry Org' end end diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb index 3b37ede8579..8815643ca96 100644 --- a/spec/features/markdown/markdown_spec.rb +++ b/spec/features/markdown/markdown_spec.rb @@ -13,7 +13,7 @@ require 'erb' # # Raw Markdown # -> `markdown` helper -# -> Redcarpet::Render::GitlabHTML converts Markdown to HTML +# -> CommonMark::Render::GitlabHTML converts Markdown to HTML # -> Post-process HTML # -> `gfm` helper # -> HTML::Pipeline @@ -324,31 +324,6 @@ describe 'GitLab Markdown', :aggregate_failures do end end - context 'Redcarpet documents' do - before do - allow_any_instance_of(Banzai::Filter::MarkdownFilter).to receive(:engine).and_return('Redcarpet') - @html = markdown(@feat.raw_markdown) - end - - it 'processes certain elements differently' do - aggregate_failures 'parses superscript' do - expect(doc).to have_selector('sup', count: 3) - end - - aggregate_failures 'permits style attribute in th elements' do - expect(doc.at_css('th:contains("Header")')['style']).to eq 'text-align: center' - expect(doc.at_css('th:contains("Row")')['style']).to eq 'text-align: right' - expect(doc.at_css('th:contains("Example")')['style']).to eq 'text-align: left' - end - - aggregate_failures 'permits style attribute in td elements' do - expect(doc.at_css('td:contains("Foo")')['style']).to eq 'text-align: center' - expect(doc.at_css('td:contains("Bar")')['style']).to eq 'text-align: right' - expect(doc.at_css('td:contains("Baz")')['style']).to eq 'text-align: left' - end - end - end - # Fake a `current_user` helper def current_user @feat.user diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index f45bcabd196..b43711f6ef6 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -10,6 +10,7 @@ describe 'User edit profile' do def submit_settings click_button 'Update profile settings' + wait_for_requests if respond_to?(:wait_for_requests) end it 'changes user profile' do @@ -35,6 +36,17 @@ describe 'User edit profile' do expect(page).to have_content('Profile was successfully updated') end + it 'shows an error if the full name contains an emoji', :js do + simulate_input('#user_name', 'Martin 😀') + submit_settings + + page.within('.qa-full-name') do + expect(page).to have_css '.gl-field-error-outline' + expect(find('.gl-field-error')).not_to have_selector('.hidden') + expect(find('.gl-field-error')).to have_content('Using emojis in names seems fun, but please try to set a status message instead') + end + end + context 'user avatar' do before do attach_file(:user_avatar, Rails.root.join('spec', 'fixtures', 'banana_sample.gif')) @@ -61,6 +73,11 @@ describe 'User edit profile' do end context 'user status', :js do + def visit_user + visit user_path(user) + wait_for_requests + end + def select_emoji(emoji_name, is_modal = false) emoji_menu_class = is_modal ? '.js-modal-status-emoji-menu' : '.js-status-emoji-menu' toggle_button = find('.js-toggle-emoji-menu') @@ -71,18 +88,16 @@ describe 'User edit profile' do context 'profile edit form' do it 'shows the user status form' do - visit(profile_path) - expect(page).to have_content('Current status') end it 'adds emoji to user status' do emoji = 'biohazard' - visit(profile_path) select_emoji(emoji) submit_settings - visit user_path(user) + visit_user + within('.cover-status') do expect(page).to have_emoji(emoji) end @@ -90,11 +105,11 @@ describe 'User edit profile' do it 'adds message to user status' do message = 'I have something to say' - visit(profile_path) fill_in 'js-status-message-field', with: message submit_settings - visit user_path(user) + visit_user + within('.cover-status') do expect(page).to have_emoji('speech_balloon') expect(page).to have_content message @@ -104,12 +119,12 @@ describe 'User edit profile' do it 'adds message and emoji to user status' do emoji = 'tanabata_tree' message = 'Playing outside' - visit(profile_path) select_emoji(emoji) fill_in 'js-status-message-field', with: message submit_settings - visit user_path(user) + visit_user + within('.cover-status') do expect(page).to have_emoji(emoji) expect(page).to have_content message @@ -119,7 +134,8 @@ describe 'User edit profile' do it 'clears the user status' do user_status = create(:user_status, user: user, message: 'Eating bread', emoji: 'stuffed_flatbread') - visit user_path(user) + visit_user + within('.cover-status') do expect(page).to have_emoji(user_status.emoji) expect(page).to have_content user_status.message @@ -129,15 +145,13 @@ describe 'User edit profile' do click_button 'js-clear-user-status-button' submit_settings - wait_for_requests + visit_user - visit user_path(user) expect(page).not_to have_selector '.cover-status' end it 'displays a default emoji if only message is entered' do message = 'a status without emoji' - visit(profile_path) fill_in 'js-status-message-field', with: message within('.js-toggle-emoji-menu') do @@ -162,6 +176,7 @@ describe 'User edit profile' do page.within "#set-user-status-modal" do click_button 'Set status' end + wait_for_requests end before do @@ -202,7 +217,8 @@ describe 'User edit profile' do select_emoji(emoji, true) set_user_status_in_modal - visit user_path(user) + visit_user + within('.cover-status') do expect(page).to have_emoji(emoji) end @@ -225,7 +241,8 @@ describe 'User edit profile' do find('.js-status-message-field').native.send_keys(message) set_user_status_in_modal - visit user_path(user) + visit_user + within('.cover-status') do expect(page).to have_emoji('speech_balloon') expect(page).to have_content message @@ -240,7 +257,8 @@ describe 'User edit profile' do find('.js-status-message-field').native.send_keys(message) set_user_status_in_modal - visit user_path(user) + visit_user + within('.cover-status') do expect(page).to have_emoji(emoji) expect(page).to have_content message @@ -250,7 +268,9 @@ describe 'User edit profile' do it 'clears the user status with the "X" button' do user_status = create(:user_status, user: user, message: 'Eating bread', emoji: 'stuffed_flatbread') - visit user_path(user) + visit_user + wait_for_requests + within('.cover-status') do expect(page).to have_emoji(user_status.emoji) expect(page).to have_content user_status.message @@ -265,14 +285,18 @@ describe 'User edit profile' do find('.js-clear-user-status-button').click set_user_status_in_modal - visit user_path(user) + visit_user + wait_for_requests + expect(page).not_to have_selector '.cover-status' end it 'clears the user status with the "Remove status" button' do user_status = create(:user_status, user: user, message: 'Eating bread', emoji: 'stuffed_flatbread') - visit user_path(user) + visit_user + wait_for_requests + within('.cover-status') do expect(page).to have_emoji(user_status.emoji) expect(page).to have_content user_status.message @@ -288,7 +312,8 @@ describe 'User edit profile' do click_button 'Remove status' end - visit user_path(user) + visit_user + expect(page).not_to have_selector '.cover-status' end diff --git a/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb b/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb index 554f0b49052..5cb015e80be 100644 --- a/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb +++ b/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb @@ -7,7 +7,7 @@ describe "User downloads artifacts" do shared_examples "downloading" do it "downloads the zip" do - expect(page.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"}) + expect(page.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename*=UTF-8''#{job.artifacts_file.filename}; filename="#{job.artifacts_file.filename}"}) expect(page.response_headers['Content-Transfer-Encoding']).to eq("binary") expect(page.response_headers['Content-Type']).to eq("application/zip") expect(page.source.b).to eq(job.artifacts_file.file.read.b) diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index e2f9e7e9cc5..3edcc7ac2cd 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -5,8 +5,8 @@ describe 'File blob', :js do let(:project) { create(:project, :public, :repository) } - def visit_blob(path, anchor: nil, ref: 'master', legacy_render: nil) - visit project_blob_path(project, File.join(ref, path), anchor: anchor, legacy_render: legacy_render) + def visit_blob(path, anchor: nil, ref: 'master') + visit project_blob_path(project, File.join(ref, path), anchor: anchor) wait_for_requests end @@ -171,21 +171,6 @@ describe 'File blob', :js do end end end - - context 'when rendering legacy markdown' do - before do - visit_blob('files/commonmark/file.md', legacy_render: 1) - - wait_for_requests - end - - it 'renders using RedCarpet' do - aggregate_failures do - expect(page).to have_content("sublist") - expect(page).to have_xpath("//ol//li//ul") - end - end - end end context 'Markdown file (stored in LFS)' do diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb index d5b20605860..6e6c299ee2e 100644 --- a/spec/features/projects/blobs/edit_spec.rb +++ b/spec/features/projects/blobs/edit_spec.rb @@ -81,17 +81,6 @@ describe 'Editing file blob', :js do expect(page).to have_content("sublist") expect(page).not_to have_xpath("//ol//li//ul") end - - it 'renders content with RedCarpet when legacy_render is set' do - visit project_edit_blob_path(project, tree_join(branch, readme_file_path), legacy_render: 1) - fill_editor(content: "1. one\\n - sublist\\n") - click_link 'Preview' - wait_for_requests - - # the above generates a sublist list in RedCarpet - expect(page).to have_content("sublist") - expect(page).to have_xpath("//ol//li//ul") - end end end diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index 24830b2bd3e..65ce872363f 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -220,7 +220,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do artifact_request = requests.find { |req| req.url.match(%r{artifacts/download}) } - expect(artifact_request.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"}) + expect(artifact_request.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename*=UTF-8''#{job.artifacts_file.filename}; filename="#{job.artifacts_file.filename}"}) expect(artifact_request.response_headers['Content-Transfer-Encoding']).to eq("binary") expect(artifact_request.response_headers['Content-Type']).to eq("image/gif") expect(artifact_request.body).to eq(job.artifacts_file.file.read.b) diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb index f505023d1d0..3b469fee867 100644 --- a/spec/features/projects/wiki/markdown_preview_spec.rb +++ b/spec/features/projects/wiki/markdown_preview_spec.rb @@ -175,20 +175,6 @@ describe 'Projects > Wiki > User previews markdown changes', :js do expect(page).to have_content("sublist") expect(page).not_to have_xpath("//ol//li//ul") end - - it 'renders content with RedCarpet when legacy_render is set' do - wiki_page = create(:wiki_page, - wiki: project.wiki, - attrs: { title: 'home', content: "Empty content" }) - visit(project_wiki_edit_path(project, wiki_page, legacy_render: 1)) - - fill_in :wiki_content, with: "1. one\n - sublist\n" - click_on "Preview" - - # the above generates a sublist list in RedCarpet - expect(page).to have_content("sublist") - expect(page).to have_xpath("//ol//li//ul") - end end end diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index f7efc3f325c..bc36c6f948f 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -110,16 +110,23 @@ describe 'Project' do it 'shows project topics' do project.update_attribute(:tag_list, 'topic1') + visit path + expect(page).to have_css('.home-panel-topic-list') - expect(page).to have_content('topic1') + expect(page).to have_link('Topic1', href: explore_projects_path(tag: 'topic1')) end it 'shows up to 3 project tags' do project.update_attribute(:tag_list, 'topic1, topic2, topic3, topic4') + visit path + expect(page).to have_css('.home-panel-topic-list') - expect(page).to have_content('topic1, topic2, topic3 + 1 more') + expect(page).to have_link('Topic1', href: explore_projects_path(tag: 'topic1')) + expect(page).to have_link('Topic2', href: explore_projects_path(tag: 'topic2')) + expect(page).to have_link('Topic3', href: explore_projects_path(tag: 'topic3')) + expect(page).to have_content('+ 1 more') end end diff --git a/spec/features/snippets/show_spec.rb b/spec/features/snippets/show_spec.rb index 367a479f62a..eb974c7c7fd 100644 --- a/spec/features/snippets/show_spec.rb +++ b/spec/features/snippets/show_spec.rb @@ -80,19 +80,6 @@ describe 'Snippet', :js do end end - context 'when rendering legacy markdown' do - before do - visit snippet_path(snippet, legacy_render: 1) - - wait_for_requests - end - - it 'renders using RedCarpet' do - expect(page).to have_content("sublist") - 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) } @@ -100,14 +87,6 @@ describe 'Snippet', :js do expect(page).not_to have_xpath("//ol//li//ul") 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) } - - it 'renders correctly' do - expect(page).to have_xpath("//ol//li//ul") - end - end end context 'switching to the simple viewer' do diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index b549f2b5c62..6fe840dccf6 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -36,19 +36,6 @@ describe 'Task Lists' do MARKDOWN end - let(:nested_tasks_markdown_redcarpet) do - <<-EOT.strip_heredoc - - [ ] Task a - - [x] Task a.1 - - [ ] Task a.2 - - [ ] Task b - - 1. [ ] Task 1 - 1. [ ] Task 1.1 - 1. [x] Task 1.2 - EOT - end - let(:nested_tasks_markdown) do <<-EOT.strip_heredoc - [ ] Task a @@ -153,61 +140,6 @@ describe 'Task Lists' do expect(page).to have_content("1 of 1 task completed") end end - - shared_examples 'shared nested tasks' do - before do - allow(Banzai::Filter::MarkdownFilter).to receive(:engine).and_return('Redcarpet') - visit_issue(project, issue) - end - it 'renders' do - expect(page).to have_selector('ul.task-list', count: 2) - expect(page).to have_selector('li.task-list-item', count: 7) - expect(page).to have_selector('ul input[checked]', count: 1) - expect(page).to have_selector('ol input[checked]', count: 1) - end - - it 'solves tasks' do - expect(page).to have_content("2 of 7 tasks completed") - - page.find('li.task-list-item', text: 'Task b').find('input').click - wait_for_requests - page.find('li.task-list-item ul li.task-list-item', text: 'Task a.2').find('input').click - wait_for_requests - page.find('li.task-list-item ol li.task-list-item', text: 'Task 1.1').find('input').click - wait_for_requests - - expect(page).to have_content("5 of 7 tasks completed") - - visit_issue(project, issue) # reload to see new system notes - - expect(page).to have_content('marked the task Task b as complete') - expect(page).to have_content('marked the task Task a.2 as complete') - expect(page).to have_content('marked the task Task 1.1 as complete') - end - end - - describe 'nested tasks', :js do - let(:cache_version) { CacheMarkdownField::CACHE_COMMONMARK_VERSION } - let!(:issue) do - create(:issue, description: nested_tasks_markdown, author: user, project: project, - cached_markdown_version: cache_version) - end - - before do - visit_issue(project, issue) - end - - context 'with Redcarpet' do - let(:cache_version) { CacheMarkdownField::CACHE_REDCARPET_VERSION } - let(:nested_tasks_markdown) { nested_tasks_markdown_redcarpet } - - it_behaves_like 'shared nested tasks' - end - - context 'with CommonMark' do - it_behaves_like 'shared nested tasks' - end - end end describe 'for Notes' do diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 682fae06434..34cb09942be 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -314,6 +314,14 @@ describe IssuesFinder do end end + context 'filtering by issue term in title' do + let(:params) { { search: 'git', in: 'title' } } + + it 'returns issues with title match for search term' do + expect(issues).to contain_exactly(issue1) + end + end + context 'filtering by issues iids' do let(:params) { { iids: issue3.iid } } diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb index e5d01c3bd03..bbeacf1707b 100644 --- a/spec/fixtures/markdown.md.erb +++ b/spec/fixtures/markdown.md.erb @@ -6,7 +6,7 @@ started. ## Markdown -GitLab uses [Redcarpet](http://git.io/ld_NVQ) to parse all Markdown into +GitLab uses [Commonmark](https://git.io/fhDag) to parse all Markdown into HTML. It has some special features. Let's try 'em out! diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index af319e5ebfe..8b82dea2524 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -188,7 +188,6 @@ describe IssuablesHelper do issuableRef: "##{issue.iid}", markdownPreviewPath: "/#{@project.full_path}/preview_markdown", markdownDocsPath: '/help/user/markdown', - markdownVersion: CacheMarkdownField::CACHE_COMMONMARK_VERSION, issuableTemplates: [], lockVersion: issue.lock_version, projectPath: @project.path, diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb index a0c0af94fa5..c3956ba08fd 100644 --- a/spec/helpers/markup_helper_spec.rb +++ b/spec/helpers/markup_helper_spec.rb @@ -212,17 +212,6 @@ describe MarkupHelper do helper.render_wiki_content(@wiki) end - it 'uses Wiki pipeline for markdown files with RedCarpet if feature disabled' do - stub_feature_flags(commonmark_for_repositories: false) - 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, markdown_engine: :redcarpet) - - helper.render_wiki_content(@wiki) - end - it "uses Asciidoctor for asciidoc files" do allow(@wiki).to receive(:format).and_return(:asciidoc) @@ -273,16 +262,6 @@ describe MarkupHelper do it 'defaults to CommonMark' do expect(helper.markup('foo.md', 'x^2')).to include('x^2') end - - it 'honors markdown_engine for RedCarpet' do - expect(helper.markup('foo.md', 'x^2', { markdown_engine: :redcarpet })).to include('x<sup>2</sup>') - end - - it 'uses RedCarpet if feature disabled' do - stub_feature_flags(commonmark_for_repositories: false) - - expect(helper.markup('foo.md', 'x^2', { markdown_engine: :redcarpet })).to include('x<sup>2</sup>') - end end describe '#first_line_in_markdown' do diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 10f61731206..49895b0680b 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -372,21 +372,16 @@ describe ProjectsHelper do end context 'when project has external wiki' do - before do - allow(project).to receive(:has_external_wiki?).and_return(true) - end - it 'includes external wiki tab' do + project.create_external_wiki_service(active: true, properties: { 'external_wiki_url' => 'https://gitlab.com' }) + is_expected.to include(:external_wiki) end end context 'when project does not have external wiki' do - before do - allow(project).to receive(:has_external_wiki?).and_return(false) - end - it 'does not include external wiki tab' do + expect(project.external_wiki).to be_nil is_expected.not_to include(:external_wiki) end end @@ -540,18 +535,6 @@ describe ProjectsHelper do end end - describe '#legacy_render_context' do - it 'returns the redcarpet engine' do - params = { legacy_render: '1' } - - expect(helper.legacy_render_context(params)).to include(markdown_engine: :redcarpet) - end - - it 'returns nothing' do - expect(helper.legacy_render_context({})).to be_empty - end - end - describe '#explore_projects_tab?' do subject { helper.explore_projects_tab? } diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index 9d55c615450..1e9470970ff 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -49,6 +49,22 @@ describe('Api', () => { }); }); + describe('groupMembers', () => { + it('fetches group members', done => { + const groupId = '54321'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/members`; + const expectedData = [{ id: 7 }]; + mock.onGet(expectedUrl).reply(200, expectedData); + + Api.groupMembers(groupId) + .then(({ data }) => { + expect(data).toEqual(expectedData); + }) + .then(done) + .catch(done.fail); + }); + }); + describe('groups', () => { it('fetches groups', done => { const query = 'dummy query'; diff --git a/spec/javascripts/diffs/components/tree_list_spec.js b/spec/javascripts/diffs/components/tree_list_spec.js index 08b0b4f9e45..c5ef48a81e9 100644 --- a/spec/javascripts/diffs/components/tree_list_spec.js +++ b/spec/javascripts/diffs/components/tree_list_spec.js @@ -83,17 +83,6 @@ describe('Diffs tree list component', () => { expect(vm.$el.querySelectorAll('.file-row')[1].textContent).toContain('app'); }); - it('filters tree list to blobs matching search', done => { - vm.search = 'app/index'; - - vm.$nextTick(() => { - expect(vm.$el.querySelectorAll('.file-row').length).toBe(1); - expect(vm.$el.querySelectorAll('.file-row')[0].textContent).toContain('index.js'); - - done(); - }); - }); - it('calls toggleTreeOpen when clicking folder', () => { spyOn(vm.$store, 'dispatch').and.stub(); @@ -130,14 +119,4 @@ describe('Diffs tree list component', () => { }); }); }); - - describe('clearSearch', () => { - it('resets search', () => { - vm.search = 'test'; - - vm.$el.querySelector('.tree-list-clear-icon').click(); - - expect(vm.search).toBe(''); - }); - }); }); diff --git a/spec/javascripts/diffs/store/getters_spec.js b/spec/javascripts/diffs/store/getters_spec.js index 190ca1230ca..4f69dc92ab8 100644 --- a/spec/javascripts/diffs/store/getters_spec.js +++ b/spec/javascripts/diffs/store/getters_spec.js @@ -242,7 +242,11 @@ describe('Diffs Module Getters', () => { }, }; - expect(getters.allBlobs(localState)).toEqual([ + expect( + getters.allBlobs(localState, { + flatBlobsList: getters.flatBlobsList(localState), + }), + ).toEqual([ { isHeader: true, path: '/', diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js index 55f40be0e4e..dc5790f6562 100644 --- a/spec/javascripts/ide/components/ide_spec.js +++ b/spec/javascripts/ide/components/ide_spec.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import Mousetrap from 'mousetrap'; import store from '~/ide/stores'; import ide from '~/ide/components/ide.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; @@ -72,73 +71,6 @@ describe('ide component', () => { }); }); - describe('file finder', () => { - beforeEach(done => { - spyOn(vm, 'toggleFileFinder'); - - vm.$store.state.fileFindVisible = true; - - vm.$nextTick(done); - }); - - it('calls toggleFileFinder on `t` key press', done => { - Mousetrap.trigger('t'); - - vm.$nextTick() - .then(() => { - expect(vm.toggleFileFinder).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - - it('calls toggleFileFinder on `command+p` key press', done => { - Mousetrap.trigger('command+p'); - - vm.$nextTick() - .then(() => { - expect(vm.toggleFileFinder).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - - it('calls toggleFileFinder on `ctrl+p` key press', done => { - Mousetrap.trigger('ctrl+p'); - - vm.$nextTick() - .then(() => { - expect(vm.toggleFileFinder).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - - it('always allows `command+p` to trigger toggleFileFinder', () => { - expect( - vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'), - ).toBe(false); - }); - - it('always allows `ctrl+p` to trigger toggleFileFinder', () => { - expect( - vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'), - ).toBe(false); - }); - - it('onlys handles `t` when focused in input-field', () => { - expect( - vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'), - ).toBe(true); - }); - - it('stops callback in monaco editor', () => { - setFixtures('<div class="inputarea"></div>'); - - expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true); - }); - }); - it('shows error message when set', done => { expect(vm.$el.querySelector('.flash-container')).toBe(null); diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index e3fd9604474..3eff3f655ee 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -232,6 +232,21 @@ describe('common_utils', () => { }); }); + describe('debounceByAnimationFrame', () => { + it('debounces a function to allow a maximum of one call per animation frame', done => { + const spy = jasmine.createSpy('spy'); + const debouncedSpy = commonUtils.debounceByAnimationFrame(spy); + window.requestAnimationFrame(() => { + debouncedSpy(); + debouncedSpy(); + window.requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(1); + done(); + }); + }); + }); + }); + describe('getParameterByName', () => { beforeEach(() => { window.history.pushState({}, null, '?scope=all&p=2'); diff --git a/spec/javascripts/lib/utils/grammar_spec.js b/spec/javascripts/lib/utils/grammar_spec.js new file mode 100644 index 00000000000..377b2ffb48c --- /dev/null +++ b/spec/javascripts/lib/utils/grammar_spec.js @@ -0,0 +1,35 @@ +import * as grammar from '~/lib/utils/grammar'; + +describe('utils/grammar', () => { + describe('toNounSeriesText', () => { + it('with empty items returns empty string', () => { + expect(grammar.toNounSeriesText([])).toBe(''); + }); + + it('with single item returns item', () => { + const items = ['Lorem Ipsum']; + + expect(grammar.toNounSeriesText(items)).toBe(items[0]); + }); + + it('with 2 items returns item1 and item2', () => { + const items = ['Dolar', 'Sit Amit']; + + expect(grammar.toNounSeriesText(items)).toBe(`${items[0]} and ${items[1]}`); + }); + + it('with 3 items returns comma separated series', () => { + const items = ['Lorem', 'Ipsum', 'dolar']; + const expected = 'Lorem, Ipsum, and dolar'; + + expect(grammar.toNounSeriesText(items)).toBe(expected); + }); + + it('with 6 items returns comma separated series', () => { + const items = ['Lorem', 'ipsum', 'dolar', 'sit', 'amit', 'consectetur']; + const expected = 'Lorem, ipsum, dolar, sit, amit, and consectetur'; + + expect(grammar.toNounSeriesText(items)).toBe(expected); + }); + }); +}); diff --git a/spec/javascripts/lib/utils/icon_utils_spec.js b/spec/javascripts/lib/utils/icon_utils_spec.js new file mode 100644 index 00000000000..3fd3940efe8 --- /dev/null +++ b/spec/javascripts/lib/utils/icon_utils_spec.js @@ -0,0 +1,67 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import * as iconUtils from '~/lib/utils/icon_utils'; + +describe('Icon utils', () => { + describe('getSvgIconPathContent', () => { + let spriteIcons; + + beforeAll(() => { + spriteIcons = gon.sprite_icons; + gon.sprite_icons = 'mockSpriteIconsEndpoint'; + }); + + afterAll(() => { + gon.sprite_icons = spriteIcons; + }); + + let axiosMock; + let mockEndpoint; + let getIcon; + const mockName = 'mockIconName'; + const mockPath = 'mockPath'; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + mockEndpoint = axiosMock.onGet(gon.sprite_icons); + getIcon = iconUtils.getSvgIconPathContent(mockName); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + it('extracts svg icon path content from sprite icons', done => { + mockEndpoint.replyOnce( + 200, + `<svg><symbol id="${mockName}"><path d="${mockPath}"/></symbol></svg>`, + ); + getIcon + .then(path => { + expect(path).toBe(mockPath); + done(); + }) + .catch(done.fail); + }); + + it('returns null if icon path content does not exist', done => { + mockEndpoint.replyOnce(200, ``); + getIcon + .then(path => { + expect(path).toBe(null); + done(); + }) + .catch(done.fail); + }); + + it('returns null if an http error occurs', done => { + mockEndpoint.replyOnce(500); + getIcon + .then(path => { + expect(path).toBe(null); + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js index 565b87de248..97b9671c809 100644 --- a/spec/javascripts/monitoring/dashboard_spec.js +++ b/spec/javascripts/monitoring/dashboard_spec.js @@ -29,7 +29,7 @@ describe('Dashboard', () => { beforeEach(() => { setFixtures(` <div class="prometheus-graphs"></div> - <div class="nav-sidebar"></div> + <div class="layout-page"></div> `); DashboardComponent = Vue.extend(Dashboard); }); @@ -164,16 +164,16 @@ describe('Dashboard', () => { jasmine.clock().uninstall(); }); - it('rerenders the dashboard when the sidebar is resized', done => { + it('sets elWidth to page width when the sidebar is resized', done => { const component = new DashboardComponent({ el: document.querySelector('.prometheus-graphs'), propsData: { ...propsData, hasMetrics: true, showPanels: false }, }); - expect(component.forceRedraw).toEqual(0); + expect(component.elWidth).toEqual(0); - const navSidebarEl = document.querySelector('.nav-sidebar'); - navSidebarEl.classList.add('nav-sidebar-collapsed'); + const pageLayoutEl = document.querySelector('.layout-page'); + pageLayoutEl.classList.add('page-with-icon-sidebar'); Vue.nextTick() .then(() => { @@ -181,7 +181,7 @@ describe('Dashboard', () => { return Vue.nextTick(); }) .then(() => { - expect(component.forceRedraw).toEqual(component.elWidth); + expect(component.elWidth).toEqual(pageLayoutEl.clientWidth); done(); }) .catch(done.fail); diff --git a/spec/javascripts/notes/components/discussion_reply_placeholder_spec.js b/spec/javascripts/notes/components/discussion_reply_placeholder_spec.js new file mode 100644 index 00000000000..07a366cf339 --- /dev/null +++ b/spec/javascripts/notes/components/discussion_reply_placeholder_spec.js @@ -0,0 +1,34 @@ +import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; + +const localVue = createLocalVue(); + +describe('ReplyPlaceholder', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(ReplyPlaceholder, { + localVue, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('emits onClick even on button click', () => { + const button = wrapper.find({ ref: 'button' }); + + button.trigger('click'); + + expect(wrapper.emitted()).toEqual({ + onClick: [[]], + }); + }); + + it('should render reply button', () => { + const button = wrapper.find({ ref: 'button' }); + + expect(button.text()).toEqual('Reply...'); + }); +}); diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js index c4b7eb17393..2eae22e095f 100644 --- a/spec/javascripts/notes/components/noteable_discussion_spec.js +++ b/spec/javascripts/notes/components/noteable_discussion_spec.js @@ -1,6 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import createStore from '~/notes/stores'; import noteableDiscussion from '~/notes/components/noteable_discussion.vue'; +import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import '~/behaviors/markdown/render_gfm'; import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data'; import mockDiffFile from '../../diffs/mock_data/diff_file'; @@ -57,27 +58,23 @@ describe('noteable_discussion component', () => { }); describe('actions', () => { - it('should render reply button', () => { - expect( - wrapper - .find('.js-vue-discussion-reply') - .text() - .trim(), - ).toEqual('Reply...'); - }); - it('should toggle reply form', done => { - wrapper.find('.js-vue-discussion-reply').trigger('click'); + const replyPlaceholder = wrapper.find(ReplyPlaceholder); - wrapper.vm.$nextTick(() => { - expect(wrapper.vm.isReplying).toEqual(true); + wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.vm.isReplying).toEqual(false); - // There is a watcher for `isReplying` which will init autosave in the next tick - wrapper.vm.$nextTick(() => { + replyPlaceholder.vm.$emit('onClick'); + }) + .then(() => wrapper.vm.$nextTick()) + .then(() => { + expect(wrapper.vm.isReplying).toEqual(true); expect(wrapper.vm.$refs.noteForm).not.toBeNull(); - done(); - }); - }); + }) + .then(done) + .catch(done.fail); }); it('does not render jump to discussion button', () => { diff --git a/spec/javascripts/ide/components/file_finder/index_spec.js b/spec/javascripts/vue_shared/components/file_finder/index_spec.js index 15ef8c31f91..bae4741f652 100644 --- a/spec/javascripts/ide/components/file_finder/index_spec.js +++ b/spec/javascripts/vue_shared/components/file_finder/index_spec.js @@ -1,54 +1,51 @@ import Vue from 'vue'; -import store from '~/ide/stores'; -import FindFileComponent from '~/ide/components/file_finder/index.vue'; +import Mousetrap from 'mousetrap'; +import FindFileComponent from '~/vue_shared/components/file_finder/index.vue'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; -import router from '~/ide/ide_router'; -import { file, resetStore } from '../../helpers'; -import { mountComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { file } from 'spec/ide/helpers'; +import timeoutPromise from 'spec/helpers/set_timeout_promise_helper'; -describe('IDE File finder item spec', () => { +describe('File finder item spec', () => { const Component = Vue.extend(FindFileComponent); let vm; - beforeEach(done => { - setFixtures('<div id="app"></div>'); - - vm = mountComponentWithStore(Component, { - store, - el: '#app', - props: { - index: 0, + function createComponent(props) { + vm = new Component({ + propsData: { + files: [], + visible: true, + loading: false, + ...props, }, }); - setTimeout(done); + vm.$mount('#app'); + } + + beforeEach(() => { + setFixtures('<div id="app"></div>'); }); afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); describe('with entries', () => { beforeEach(done => { - Vue.set(vm.$store.state.entries, 'folder', { - ...file('folder'), - path: 'folder', - type: 'folder', - }); - - Vue.set(vm.$store.state.entries, 'index.js', { - ...file('index.js'), - path: 'index.js', - type: 'blob', - url: '/index.jsurl', - }); - - Vue.set(vm.$store.state.entries, 'component.js', { - ...file('component.js'), - path: 'component.js', - type: 'blob', + createComponent({ + files: [ + { + ...file('index.js'), + path: 'index.js', + type: 'blob', + url: '/index.jsurl', + }, + { + ...file('component.js'), + path: 'component.js', + type: 'blob', + }, + ], }); setTimeout(done); @@ -56,13 +53,14 @@ describe('IDE File finder item spec', () => { it('renders list of blobs', () => { expect(vm.$el.textContent).toContain('index.js'); + expect(vm.$el.textContent).toContain('component.js'); expect(vm.$el.textContent).not.toContain('folder'); }); it('filters entries', done => { vm.searchText = 'index'; - vm.$nextTick(() => { + setTimeout(() => { expect(vm.$el.textContent).toContain('index.js'); expect(vm.$el.textContent).not.toContain('component.js'); @@ -73,8 +71,8 @@ describe('IDE File finder item spec', () => { it('shows clear button when searchText is not empty', done => { vm.searchText = 'index'; - vm.$nextTick(() => { - expect(vm.$el.querySelector('.dropdown-input-clear').classList).toContain('show'); + setTimeout(() => { + expect(vm.$el.querySelector('.dropdown-input').classList).toContain('has-value'); expect(vm.$el.querySelector('.dropdown-input-search').classList).toContain('hidden'); done(); @@ -84,11 +82,11 @@ describe('IDE File finder item spec', () => { it('clear button resets searchText', done => { vm.searchText = 'index'; - vm.$nextTick() + timeoutPromise() .then(() => { vm.$el.querySelector('.dropdown-input-clear').click(); }) - .then(vm.$nextTick) + .then(timeoutPromise) .then(() => { expect(vm.searchText).toBe(''); }) @@ -100,11 +98,11 @@ describe('IDE File finder item spec', () => { spyOn(vm.$refs.searchInput, 'focus'); vm.searchText = 'index'; - vm.$nextTick() + timeoutPromise() .then(() => { vm.$el.querySelector('.dropdown-input-clear').click(); }) - .then(vm.$nextTick) + .then(timeoutPromise) .then(() => { expect(vm.$refs.searchInput.focus).toHaveBeenCalled(); }) @@ -116,7 +114,7 @@ describe('IDE File finder item spec', () => { it('returns 1 when no filtered entries exist', done => { vm.searchText = 'testing 123'; - vm.$nextTick(() => { + setTimeout(() => { expect(vm.listShowCount).toBe(1); done(); @@ -136,7 +134,7 @@ describe('IDE File finder item spec', () => { it('returns 33 when entries dont exist', done => { vm.searchText = 'testing 123'; - vm.$nextTick(() => { + setTimeout(() => { expect(vm.listHeight).toBe(33); done(); @@ -148,7 +146,7 @@ describe('IDE File finder item spec', () => { it('returns length of filtered blobs', done => { vm.searchText = 'index'; - vm.$nextTick(() => { + setTimeout(() => { expect(vm.filteredBlobsLength).toBe(1); done(); @@ -162,7 +160,7 @@ describe('IDE File finder item spec', () => { vm.focusedIndex = 1; vm.searchText = 'test'; - vm.$nextTick(() => { + setTimeout(() => { expect(vm.focusedIndex).toBe(0); done(); @@ -170,16 +168,16 @@ describe('IDE File finder item spec', () => { }); }); - describe('fileFindVisible', () => { + describe('visible', () => { it('returns searchText when false', done => { vm.searchText = 'test'; - vm.$store.state.fileFindVisible = true; + vm.visible = true; - vm.$nextTick() + timeoutPromise() .then(() => { - vm.$store.state.fileFindVisible = false; + vm.visible = false; }) - .then(vm.$nextTick) + .then(timeoutPromise) .then(() => { expect(vm.searchText).toBe(''); }) @@ -191,20 +189,19 @@ describe('IDE File finder item spec', () => { describe('openFile', () => { beforeEach(() => { - spyOn(router, 'push'); - spyOn(vm, 'toggleFileFinder'); + spyOn(vm, '$emit'); }); it('closes file finder', () => { - vm.openFile(vm.$store.state.entries['index.js']); + vm.openFile(vm.files[0]); - expect(vm.toggleFileFinder).toHaveBeenCalled(); + expect(vm.$emit).toHaveBeenCalledWith('toggle', false); }); it('pushes to router', () => { - vm.openFile(vm.$store.state.entries['index.js']); + vm.openFile(vm.files[0]); - expect(router.push).toHaveBeenCalledWith('/project/index.jsurl'); + expect(vm.$emit).toHaveBeenCalledWith('click', vm.files[0]); }); }); @@ -217,8 +214,8 @@ describe('IDE File finder item spec', () => { vm.$refs.searchInput.dispatchEvent(event); - vm.$nextTick(() => { - expect(vm.openFile).toHaveBeenCalledWith(vm.$store.state.entries['index.js']); + setTimeout(() => { + expect(vm.openFile).toHaveBeenCalledWith(vm.files[0]); done(); }); @@ -228,12 +225,12 @@ describe('IDE File finder item spec', () => { const event = new CustomEvent('keyup'); event.keyCode = ESC_KEY_CODE; - spyOn(vm, 'toggleFileFinder'); + spyOn(vm, '$emit'); vm.$refs.searchInput.dispatchEvent(event); - vm.$nextTick(() => { - expect(vm.toggleFileFinder).toHaveBeenCalled(); + setTimeout(() => { + expect(vm.$emit).toHaveBeenCalledWith('toggle', false); done(); }); @@ -287,18 +284,85 @@ describe('IDE File finder item spec', () => { }); describe('without entries', () => { - it('renders loading text when loading', done => { - store.state.loading = true; - - vm.$nextTick(() => { - expect(vm.$el.textContent).toContain('Loading...'); - - done(); + it('renders loading text when loading', () => { + createComponent({ + loading: true, }); + + expect(vm.$el.textContent).toContain('Loading...'); }); it('renders no files text', () => { + createComponent(); + expect(vm.$el.textContent).toContain('No files found.'); }); }); + + describe('keyboard shortcuts', () => { + beforeEach(done => { + createComponent(); + + spyOn(vm, 'toggle'); + + vm.$nextTick(done); + }); + + it('calls toggle on `t` key press', done => { + Mousetrap.trigger('t'); + + vm.$nextTick() + .then(() => { + expect(vm.toggle).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('calls toggle on `command+p` key press', done => { + Mousetrap.trigger('command+p'); + + vm.$nextTick() + .then(() => { + expect(vm.toggle).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('calls toggle on `ctrl+p` key press', done => { + Mousetrap.trigger('ctrl+p'); + + vm.$nextTick() + .then(() => { + expect(vm.toggle).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('always allows `command+p` to trigger toggle', () => { + expect( + vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'), + ).toBe(false); + }); + + it('always allows `ctrl+p` to trigger toggle', () => { + expect( + vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'), + ).toBe(false); + }); + + it('onlys handles `t` when focused in input-field', () => { + expect( + vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'), + ).toBe(true); + }); + + it('stops callback in monaco editor', () => { + setFixtures('<div class="inputarea"></div>'); + + expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true); + }); + }); }); diff --git a/spec/javascripts/ide/components/file_finder/item_spec.js b/spec/javascripts/vue_shared/components/file_finder/item_spec.js index 0f1116c6912..c1511643a9d 100644 --- a/spec/javascripts/ide/components/file_finder/item_spec.js +++ b/spec/javascripts/vue_shared/components/file_finder/item_spec.js @@ -1,9 +1,9 @@ import Vue from 'vue'; -import ItemComponent from '~/ide/components/file_finder/item.vue'; -import { file } from '../../helpers'; +import ItemComponent from '~/vue_shared/components/file_finder/item.vue'; +import { file } from 'spec/ide/helpers'; import createComponent from '../../../helpers/vue_mount_component_helper'; -describe('IDE File finder item spec', () => { +describe('File finder item spec', () => { const Component = Vue.extend(ItemComponent); let vm; let localFile; diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb index e1aea82653d..08165f147bb 100644 --- a/spec/lib/api/helpers_spec.rb +++ b/spec/lib/api/helpers_spec.rb @@ -179,7 +179,7 @@ describe API::Helpers do context 'when blob name is not null' do it 'returns disposition with the blob name' do - expect(send_git_blob['Content-Disposition']).to eq 'inline; filename="foobar"' + expect(send_git_blob['Content-Disposition']).to eq %q(inline; filename="foobar"; filename*=UTF-8''foobar) end end end diff --git a/spec/lib/banzai/filter/autolink_filter_spec.rb b/spec/lib/banzai/filter/autolink_filter_spec.rb index 6217381c491..4972c4b4bd2 100644 --- a/spec/lib/banzai/filter/autolink_filter_spec.rb +++ b/spec/lib/banzai/filter/autolink_filter_spec.rb @@ -121,6 +121,13 @@ describe Banzai::Filter::AutolinkFilter do expect(doc.to_s).to eq("See #{link}") end + it 'does not autolink bad URLs after we remove trailing punctuation' do + link = 'http://]' + doc = filter("See #{link}") + + expect(doc.to_s).to eq("See #{link}") + end + it 'does not include trailing punctuation' do ['.', ', ok?', '...', '?', '!', ': is that ok?'].each do |trailing_punctuation| doc = filter("See #{link}#{trailing_punctuation}") diff --git a/spec/lib/banzai/filter/markdown_filter_spec.rb b/spec/lib/banzai/filter/markdown_filter_spec.rb index 4c4e821deab..83fcda29680 100644 --- a/spec/lib/banzai/filter/markdown_filter_spec.rb +++ b/spec/lib/banzai/filter/markdown_filter_spec.rb @@ -10,12 +10,6 @@ describe Banzai::Filter::MarkdownFilter do filter('test') end - it 'uses Redcarpet' do - expect_any_instance_of(Banzai::Filter::MarkdownEngines::Redcarpet).to receive(:render).and_return('test') - - filter('test', { markdown_engine: :redcarpet }) - end - it 'uses CommonMark' do expect_any_instance_of(Banzai::Filter::MarkdownEngines::CommonMark).to receive(:render).and_return('test') @@ -47,24 +41,6 @@ describe Banzai::Filter::MarkdownFilter do expect(result).to start_with('<pre><code lang="日">') 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 describe 'source line position' do @@ -85,18 +61,6 @@ describe Banzai::Filter::MarkdownFilter do expect(result).to eq '<p>test</p>' end end - - context 'using Redcarpet' do - before do - stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :redcarpet) - end - - it 'does not support data-sourcepos' do - result = filter('test') - - expect(result).to eq '<p>test</p>' - end - end end describe 'footnotes in tables' do diff --git a/spec/lib/banzai/filter/spaced_link_filter_spec.rb b/spec/lib/banzai/filter/spaced_link_filter_spec.rb index 1ad7f3ff567..76d7644d76c 100644 --- a/spec/lib/banzai/filter/spaced_link_filter_spec.rb +++ b/spec/lib/banzai/filter/spaced_link_filter_spec.rb @@ -26,11 +26,6 @@ describe Banzai::Filter::SpacedLinkFilter do expect(doc.at_css('p')).to be_nil end - it 'does nothing when markdown_engine is redcarpet' do - exp = act = link - expect(filter(act, markdown_engine: :redcarpet).to_html).to eq exp - end - it 'does nothing with empty text' do link = '[](page slug)' doc = filter("See #{link}") diff --git a/spec/lib/gitlab/background_migration_spec.rb b/spec/lib/gitlab/background_migration_spec.rb index 8a83b76fd94..7d3d8a949ef 100644 --- a/spec/lib/gitlab/background_migration_spec.rb +++ b/spec/lib/gitlab/background_migration_spec.rb @@ -104,6 +104,38 @@ describe Gitlab::BackgroundMigration do end end end + + context 'when retry_dead_jobs is true', :sidekiq, :redis do + let(:retry_queue) do + [double(args: ['Object', [3]], queue: described_class.queue, delete: true)] + end + let(:dead_queue) do + [double(args: ['Object', [4]], queue: described_class.queue, delete: true)] + end + + before do + allow(Sidekiq::RetrySet).to receive(:new).and_return(retry_queue) + allow(Sidekiq::DeadSet).to receive(:new).and_return(dead_queue) + end + + it 'steals from the dead and retry queue' do + Sidekiq::Testing.disable! do + expect(described_class).to receive(:perform) + .with('Object', [1]).ordered + expect(described_class).to receive(:perform) + .with('Object', [2]).ordered + expect(described_class).to receive(:perform) + .with('Object', [3]).ordered + expect(described_class).to receive(:perform) + .with('Object', [4]).ordered + + BackgroundMigrationWorker.perform_async('Object', [2]) + BackgroundMigrationWorker.perform_in(10.minutes, 'Object', [1]) + + described_class.steal('Object', retry_dead_jobs: true) + end + end + end end describe '.perform' do diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index fe2087e8fc3..baca8f6d542 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -603,5 +603,6 @@ ResourceLabelEvent: ErrorTracking::ProjectErrorTrackingSetting: - id - api_url -- enabled - project_id +- project_name +- organization_name diff --git a/spec/migrations/fix_null_type_labels_spec.rb b/spec/migrations/fix_null_type_labels_spec.rb new file mode 100644 index 00000000000..462ae9b913f --- /dev/null +++ b/spec/migrations/fix_null_type_labels_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20190131122559_fix_null_type_labels') + +describe FixNullTypeLabels, :migration do + let(:migration) { described_class.new } + let(:projects) { table(:projects) } + let(:namespaces) { table(:namespaces) } + let(:labels) { table(:labels) } + + before do + group = namespaces.create(name: 'labels-test-project', path: 'labels-test-project', type: 'Group') + project = projects.create!(namespace_id: group.id, name: 'labels-test-group', path: 'labels-test-group') + + @template_label = labels.create(title: 'template', template: true) + @project_label = labels.create(title: 'project label', project_id: project.id, type: 'ProjectLabel') + @group_label = labels.create(title: 'group_label', group_id: group.id, type: 'GroupLabel') + @broken_label_1 = labels.create(title: 'broken 1', project_id: project.id) + @broken_label_2 = labels.create(title: 'broken 2', project_id: project.id) + end + + describe '#up' do + it 'fix labels with type missing' do + migration.up + + # Labels that requires type change + expect(@broken_label_1.reload.type).to eq('ProjectLabel') + expect(@broken_label_2.reload.type).to eq('ProjectLabel') + # Labels out of scope + expect(@template_label.reload.type).to be_nil + expect(@project_label.reload.type).to eq('ProjectLabel') + expect(@group_label.reload.type).to eq('GroupLabel') + end + end +end diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb index 68aed387bfc..ca23f581fdc 100644 --- a/spec/models/application_record_spec.rb +++ b/spec/models/application_record_spec.rb @@ -10,4 +10,14 @@ describe ApplicationRecord do expect(User.id_in(records.last(2).map(&:id))).to eq(records.last(2)) end end + + describe '#safe_find_or_create_by' do + it 'creates the user avoiding race conditions' do + expect(Suggestion).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique) + allow(Suggestion).to receive(:find_or_create_by).and_call_original + + expect { Suggestion.safe_find_or_create_by(build(:suggestion).attributes) } + .to change { Suggestion.count }.by(1) + end + end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 8a1bbb26e57..47865e4d08f 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1844,6 +1844,26 @@ describe Ci::Build do context 'when there is no environment' do it { is_expected.to be_nil } end + + context 'when build has a start environment' do + let(:build) { create(:ci_build, :deploy_to_production, pipeline: pipeline) } + + it 'does not expand environment name' do + expect(build).not_to receive(:expanded_environment_name) + + subject + end + end + + context 'when build has a stop environment' do + let(:build) { create(:ci_build, :stop_review_app, pipeline: pipeline) } + + it 'expands environment name' do + expect(build).to receive(:expanded_environment_name) + + subject + end + end end describe '#play' do diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb index 925e2ab0955..29197ef372e 100644 --- a/spec/models/concerns/cache_markdown_field_spec.rb +++ b/spec/models/concerns/cache_markdown_field_spec.rb @@ -73,6 +73,7 @@ describe CacheMarkdownField do let(:updated_html) { '<p dir="auto"><code>Bar</code></p>' } let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION) } + let(:cache_version) { CacheMarkdownField::CACHE_COMMONMARK_VERSION } before do stub_commonmark_sourcepos_disabled @@ -97,20 +98,15 @@ describe CacheMarkdownField do end context 'a changed markdown field' do - shared_examples 'with cache version' do |cache_version| - let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) } + let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) } - before do - thing.foo = updated_markdown - thing.save - end - - it { expect(thing.foo_html).to eq(updated_html) } - it { expect(thing.cached_markdown_version).to eq(cache_version) } + before do + thing.foo = updated_markdown + thing.save end - it_behaves_like 'with cache version', CacheMarkdownField::CACHE_REDCARPET_VERSION - it_behaves_like 'with cache version', CacheMarkdownField::CACHE_COMMONMARK_VERSION + it { expect(thing.foo_html).to eq(updated_html) } + it { expect(thing.cached_markdown_version).to eq(cache_version) } end context 'when a markdown field is set repeatedly to an empty string' do @@ -143,22 +139,17 @@ describe CacheMarkdownField do end context 'a non-markdown field changed' do - shared_examples 'with cache version' do |cache_version| - let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) } - - before do - thing.bar = 'OK' - thing.save - end + let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) } - it { expect(thing.bar).to eq('OK') } - it { expect(thing.foo).to eq(markdown) } - it { expect(thing.foo_html).to eq(html) } - it { expect(thing.cached_markdown_version).to eq(cache_version) } + before do + thing.bar = 'OK' + thing.save end - it_behaves_like 'with cache version', CacheMarkdownField::CACHE_REDCARPET_VERSION - it_behaves_like 'with cache version', CacheMarkdownField::CACHE_COMMONMARK_VERSION + it { expect(thing.bar).to eq('OK') } + it { expect(thing.foo).to eq(markdown) } + it { expect(thing.foo_html).to eq(html) } + it { expect(thing.cached_markdown_version).to eq(cache_version) } end context 'version is out of date' do @@ -173,73 +164,63 @@ describe CacheMarkdownField do end describe '#cached_html_up_to_date?' do - shared_examples 'with cache version' do |cache_version| - let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) } + let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) } - subject { thing.cached_html_up_to_date?(:foo) } + subject { thing.cached_html_up_to_date?(:foo) } - it 'returns false when the version is absent' do - thing.cached_markdown_version = nil + it 'returns false when the version is absent' do + thing.cached_markdown_version = nil - is_expected.to be_falsy - end + is_expected.to be_falsy + end - it 'returns false when the version is too early' do - thing.cached_markdown_version -= 1 + it 'returns false when the version is too early' do + thing.cached_markdown_version -= 1 - is_expected.to be_falsy - end + is_expected.to be_falsy + end - it 'returns false when the version is too late' do - thing.cached_markdown_version += 1 + it 'returns false when the version is too late' do + thing.cached_markdown_version += 1 - is_expected.to be_falsy - end + is_expected.to be_falsy + end - it 'returns true when the version is just right' do - thing.cached_markdown_version = cache_version + it 'returns true when the version is just right' do + thing.cached_markdown_version = cache_version - is_expected.to be_truthy - end + is_expected.to be_truthy + end - it 'returns false if markdown has been changed but html has not' do - thing.foo = updated_html + it 'returns false if markdown has been changed but html has not' do + thing.foo = updated_html - is_expected.to be_falsy - end + is_expected.to be_falsy + end - it 'returns true if markdown has not been changed but html has' do - thing.foo_html = updated_html + it 'returns true if markdown has not been changed but html has' do + thing.foo_html = updated_html - is_expected.to be_truthy - end + is_expected.to be_truthy + end - it 'returns true if markdown and html have both been changed' do - thing.foo = updated_markdown - thing.foo_html = updated_html + it 'returns true if markdown and html have both been changed' do + thing.foo = updated_markdown + thing.foo_html = updated_html - is_expected.to be_truthy - end + is_expected.to be_truthy + end - it 'returns false if the markdown field is set but the html is not' do - thing.foo_html = nil + it 'returns false if the markdown field is set but the html is not' do + thing.foo_html = nil - is_expected.to be_falsy - end + is_expected.to be_falsy end - - it_behaves_like 'with cache version', CacheMarkdownField::CACHE_REDCARPET_VERSION - it_behaves_like 'with cache version', CacheMarkdownField::CACHE_COMMONMARK_VERSION end describe '#latest_cached_markdown_version' do subject { thing.latest_cached_markdown_version } - it 'returns redcarpet version' do - thing.cached_markdown_version = CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - 1 - is_expected.to eq(CacheMarkdownField::CACHE_REDCARPET_VERSION) - end - it 'returns commonmark version' do thing.cached_markdown_version = CacheMarkdownField::CACHE_COMMONMARK_VERSION_START + 1 is_expected.to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION) @@ -251,30 +232,6 @@ describe CacheMarkdownField do end end - describe '#legacy_markdown?' do - subject { thing.legacy_markdown? } - - it 'returns true for redcarpet versions' do - thing.cached_markdown_version = CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - 1 - is_expected.to be_truthy - end - - it 'returns false for commonmark versions' do - thing.cached_markdown_version = CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - is_expected.to be_falsey - end - - it 'returns false if nil' do - thing.cached_markdown_version = nil - is_expected.to be_falsey - end - - it 'returns false if 0' do - thing.cached_markdown_version = 0 - is_expected.to be_falsey - end - end - describe '#refresh_markdown_cache' do before do thing.foo = updated_markdown @@ -303,39 +260,34 @@ describe CacheMarkdownField do end describe '#refresh_markdown_cache!' do - shared_examples 'with cache version' do |cache_version| - let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) } + let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) } - before do - thing.foo = updated_markdown - end + before do + thing.foo = updated_markdown + end - it 'fills all html fields' do - thing.refresh_markdown_cache! + it 'fills all html fields' do + thing.refresh_markdown_cache! - expect(thing.foo_html).to eq(updated_html) - expect(thing.foo_html_changed?).to be_truthy - expect(thing.baz_html_changed?).to be_truthy - end + expect(thing.foo_html).to eq(updated_html) + expect(thing.foo_html_changed?).to be_truthy + expect(thing.baz_html_changed?).to be_truthy + end - it 'skips saving if not persisted' do - expect(thing).to receive(:persisted?).and_return(false) - expect(thing).not_to receive(:update_columns) + it 'skips saving if not persisted' do + expect(thing).to receive(:persisted?).and_return(false) + expect(thing).not_to receive(:update_columns) - thing.refresh_markdown_cache! - end + thing.refresh_markdown_cache! + end - it 'saves the changes using #update_columns' do - expect(thing).to receive(:persisted?).and_return(true) - expect(thing).to receive(:update_columns) - .with("foo_html" => updated_html, "baz_html" => "", "cached_markdown_version" => cache_version) + it 'saves the changes using #update_columns' do + expect(thing).to receive(:persisted?).and_return(true) + expect(thing).to receive(:update_columns) + .with("foo_html" => updated_html, "baz_html" => "", "cached_markdown_version" => cache_version) - thing.refresh_markdown_cache! - end + thing.refresh_markdown_cache! end - - it_behaves_like 'with cache version', CacheMarkdownField::CACHE_REDCARPET_VERSION - it_behaves_like 'with cache version', CacheMarkdownField::CACHE_COMMONMARK_VERSION end describe '#banzai_render_context' do @@ -408,20 +360,4 @@ 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/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 5753c646106..41159348e04 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -139,6 +139,78 @@ describe Issuable do it 'returns issues with a matching description for a query shorter than 3 chars' do expect(issuable_class.full_search(searchable_issue2.description.downcase)).to eq([searchable_issue2]) end + + context 'when matching columns is "title"' do + it 'returns issues with a matching title' do + expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'title')) + .to eq([searchable_issue]) + end + + it 'returns no issues with a matching description' do + expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'title')) + .to be_empty + end + end + + context 'when matching columns is "description"' do + it 'returns no issues with a matching title' do + expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'description')) + .to be_empty + end + + it 'returns issues with a matching description' do + expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'description')) + .to eq([searchable_issue]) + end + end + + context 'when matching columns is "title,description"' do + it 'returns issues with a matching title' do + expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'title,description')) + .to eq([searchable_issue]) + end + + it 'returns issues with a matching description' do + expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'title,description')) + .to eq([searchable_issue]) + end + end + + context 'when matching columns is nil"' do + it 'returns issues with a matching title' do + expect(issuable_class.full_search(searchable_issue.title, matched_columns: nil)) + .to eq([searchable_issue]) + end + + it 'returns issues with a matching description' do + expect(issuable_class.full_search(searchable_issue.description, matched_columns: nil)) + .to eq([searchable_issue]) + end + end + + context 'when matching columns is "invalid"' do + it 'returns issues with a matching title' do + expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'invalid')) + .to eq([searchable_issue]) + end + + it 'returns issues with a matching description' do + expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'invalid')) + .to eq([searchable_issue]) + end + end + + context 'when matching columns is "title,invalid"' do + it 'returns issues with a matching title' do + expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'title,invalid')) + .to eq([searchable_issue]) + end + + it 'returns no issues with a matching description' do + expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'title,invalid')) + .to be_empty + end + end end describe '.to_ability_name' do diff --git a/spec/models/error_tracking/project_error_tracking_setting_spec.rb b/spec/models/error_tracking/project_error_tracking_setting_spec.rb index 2f8ab21d4b2..d30228b863c 100644 --- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb +++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb @@ -15,9 +15,11 @@ describe ErrorTracking::ProjectErrorTrackingSetting do describe 'Validations' do context 'when api_url is over 255 chars' do - it 'fails validation' do + before do subject.api_url = 'https://' + 'a' * 250 + end + it 'fails validation' do expect(subject).not_to be_valid expect(subject.errors.messages[:api_url]).to include('is too long (maximum is 255 characters)') end @@ -31,6 +33,34 @@ describe ErrorTracking::ProjectErrorTrackingSetting do end end + context 'presence validations' do + using RSpec::Parameterized::TableSyntax + + valid_api_url = 'http://example.com/api/0/projects/org-slug/proj-slug/' + valid_token = 'token' + + where(:enabled, :token, :api_url, :valid?) do + true | nil | nil | false + true | nil | valid_api_url | false + true | valid_token | nil | false + true | valid_token | valid_api_url | true + false | nil | nil | true + false | nil | valid_api_url | true + false | valid_token | nil | true + false | valid_token | valid_api_url | true + end + + with_them do + before do + subject.enabled = enabled + subject.token = token + subject.api_url = api_url + end + + it { expect(subject.valid?).to eq(valid?) } + end + end + context 'URL path' do it 'fails validation with wrong path' do subject.api_url = 'http://gitlab.com/project1/something' @@ -45,6 +75,16 @@ describe ErrorTracking::ProjectErrorTrackingSetting do expect(subject).to be_valid end end + + context 'non ascii chars in api_url' do + before do + subject.api_url = 'http://gitlab.com/api/0/projects/project1/something€' + end + + it 'fails validation' do + expect(subject).not_to be_valid + end + end end describe '#sentry_external_url' do @@ -106,4 +146,138 @@ describe ErrorTracking::ProjectErrorTrackingSetting do end end end + + describe '#list_sentry_projects' do + let(:projects) { [:list, :of, :projects] } + let(:sentry_client) { spy(:sentry_client) } + + it 'calls sentry client' do + expect(subject).to receive(:sentry_client).and_return(sentry_client) + expect(sentry_client).to receive(:list_projects).and_return(projects) + + result = subject.list_sentry_projects + + expect(result).to eq(projects: projects) + end + end + + context 'slugs' do + shared_examples_for 'slug from api_url' do |method, slug| + context 'when api_url is correct' do + before do + subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug/' + end + + it 'returns slug' do + expect(subject.public_send(method)).to eq(slug) + end + end + + context 'when api_url is blank' do + before do + subject.api_url = nil + end + + it 'returns nil' do + expect(subject.public_send(method)).to be_nil + end + end + end + + it_behaves_like 'slug from api_url', :project_slug, 'project-slug' + it_behaves_like 'slug from api_url', :organization_slug, 'org-slug' + end + + context 'names from api_url' do + shared_examples_for 'name from api_url' do |name, titleized_slug| + context 'name is present in DB' do + it 'returns name from DB' do + subject[name] = 'Sentry name' + subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug' + + expect(subject.public_send(name)).to eq('Sentry name') + end + end + + context 'name is null in DB' do + it 'titleizes and returns slug from api_url' do + subject[name] = nil + subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug' + + expect(subject.public_send(name)).to eq(titleized_slug) + end + + it 'returns nil when api_url is incorrect' do + subject[name] = nil + subject.api_url = 'http://gitlab.com/api/0/projects/' + + expect(subject.public_send(name)).to be_nil + end + + it 'returns nil when api_url is blank' do + subject[name] = nil + subject.api_url = nil + + expect(subject.public_send(name)).to be_nil + end + end + end + + it_behaves_like 'name from api_url', :organization_name, 'Org Slug' + it_behaves_like 'name from api_url', :project_name, 'Project Slug' + end + + describe '.build_api_url_from' do + it 'correctly builds api_url with slugs' do + api_url = described_class.build_api_url_from( + api_host: 'http://sentry.com/', + organization_slug: 'org-slug', + project_slug: 'proj-slug' + ) + + expect(api_url).to eq('http://sentry.com/api/0/projects/org-slug/proj-slug/') + end + + it 'correctly builds api_url without slugs' do + api_url = described_class.build_api_url_from( + api_host: 'http://sentry.com/', + organization_slug: nil, + project_slug: nil + ) + + expect(api_url).to eq('http://sentry.com/api/0/projects/') + end + + it 'does not raise exception with invalid url' do + api_url = described_class.build_api_url_from( + api_host: ':::', + organization_slug: 'org-slug', + project_slug: 'proj-slug' + ) + + expect(api_url).to eq(':::') + end + end + + describe '#api_host' do + context 'when api_url exists' do + before do + subject.api_url = 'https://example.com/api/0/projects/org-slug/proj-slug/' + end + + it 'extracts the api_host from api_url' do + expect(subject.api_host).to eq('https://example.com/') + end + end + + context 'when api_url is nil' do + before do + subject.api_url = nil + end + + it 'returns nil' do + expect(subject.api_url).to eq(nil) + end + end + end end diff --git a/spec/models/ssh_host_key_spec.rb b/spec/models/ssh_host_key_spec.rb index 75db43b3d56..4c677569561 100644 --- a/spec/models/ssh_host_key_spec.rb +++ b/spec/models/ssh_host_key_spec.rb @@ -50,6 +50,35 @@ describe SshHostKey do subject(:ssh_host_key) { described_class.new(project: project, url: 'ssh://example.com:2222', compare_host_keys: compare_host_keys) } + describe '.primary_key' do + it 'returns a symbol' do + expect(described_class.primary_key).to eq(:id) + end + end + + describe '.find_by' do + let(:project) { create(:project) } + let(:url) { 'ssh://invalid.invalid:2222' } + + let(:finding_id) { [project.id, url].join(':') } + + it 'accepts a string key' do + result = described_class.find_by('id' => finding_id) + + expect(result).to be_a(described_class) + expect(result.project).to eq(project) + expect(result.url.to_s).to eq(url) + end + + it 'accepts a symbol key' do + result = described_class.find_by(id: finding_id) + + expect(result).to be_a(described_class) + expect(result.project).to eq(project) + expect(result.url.to_s).to eq(url) + end + end + describe '#fingerprints', :use_clean_rails_memory_store_caching do it 'returns an array of indexed fingerprints when the cache is filled' do stub_reactive_cache(ssh_host_key, known_hosts: known_hosts) diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index 9b32dc78274..1ad536258ba 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -191,7 +191,7 @@ describe API::Files do get api(url, current_user), params: params - expect(headers['Content-Disposition']).to eq('inline; filename="popen.rb"') + expect(headers['Content-Disposition']).to eq(%q(inline; filename="popen.rb"; filename*=UTF-8''popen.rb)) end context 'when mandatory params are not given' do diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index e0f1e303e96..04908378a24 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -208,6 +208,18 @@ describe API::Issues do expect_paginated_array_response(issue.id) end + it 'returns issues matching given search string for title and scoped in title' do + get api("/issues", user), params: { search: issue.title, in: 'title' } + + expect_paginated_array_response(issue.id) + end + + it 'returns an empty array if no issue matches given search string for title and scoped in description' do + get api("/issues", user), params: { search: issue.title, in: 'description' } + + expect_paginated_array_response([]) + end + it 'returns issues matching given search string for description' do get api("/issues", user), params: { search: issue.description } diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb index 97aa71bf231..3defe8bbf51 100644 --- a/spec/requests/api/jobs_spec.rb +++ b/spec/requests/api/jobs_spec.rb @@ -403,7 +403,7 @@ describe API::Jobs do shared_examples 'downloads artifact' do let(:download_headers) do { 'Content-Transfer-Encoding' => 'binary', - 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' } + 'Content-Disposition' => %q(attachment; filename="ci_build_artifacts.zip"; filename*=UTF-8''ci_build_artifacts.zip) } end it 'returns specific job artifacts' do @@ -555,7 +555,7 @@ describe API::Jobs do let(:download_headers) do { 'Content-Transfer-Encoding' => 'binary', 'Content-Disposition' => - "attachment; filename=#{job.artifacts_file.filename}" } + %Q(attachment; filename="#{job.artifacts_file.filename}"; filename*=UTF-8''#{job.artifacts_file.filename}) } end it { expect(response).to have_http_status(:ok) } diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 4d42bc39ac3..51343287a13 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -260,6 +260,18 @@ describe API::MergeRequests do expect_response_ordered_exactly(merge_request) end + it 'returns merge requests matching given search string for title and scoped in title' do + get api("/merge_requests", user), params: { search: merge_request.title, in: 'title' } + + expect_response_ordered_exactly(merge_request) + end + + it 'returns an empty array if no merge reques matches given search string for description and scoped in title' do + get api("/merge_requests", user), params: { search: merge_request.description, in: 'title' } + + expect_response_contain_exactly + end + it 'returns merge requests for project matching given search string for description' do get api("/merge_requests", user), params: { project_id: project.id, search: merge_request.description } diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 70686158b7d..cfa7a1a31a3 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -49,6 +49,27 @@ describe API::Projects do namespace: user4.namespace) end + shared_context 'with language detection' do + let(:ruby) { create(:programming_language, name: 'Ruby') } + let(:javascript) { create(:programming_language, name: 'JavaScript') } + let(:html) { create(:programming_language, name: 'HTML') } + + let(:mock_repo_languages) do + { + project => { ruby => 0.5, html => 0.5 }, + project3 => { html => 0.7, javascript => 0.3 } + } + end + + before do + mock_repo_languages.each do |proj, lang_shares| + lang_shares.each do |lang, share| + create(:repository_language, project: proj, programming_language: lang, share: share) + end + end + end + end + describe 'GET /projects' do shared_examples_for 'projects response' do it 'returns an array of projects' do @@ -344,6 +365,19 @@ describe API::Projects do end end + context 'and using the programming language filter' do + include_context 'with language detection' + + it 'filters case-insensitively by programming language' do + get api('/projects', user), params: { with_programming_language: 'javascript' } + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |p| p['id'] }).to contain_exactly(project3.id) + end + end + context 'and using sorting' do it 'returns the correct order when sorted by id' do get api('/projects', user), params: { order_by: 'id', sort: 'desc' } @@ -755,6 +789,19 @@ describe API::Projects do expect(json_response).to be_an Array expect(json_response.map { |project| project['id'] }).to contain_exactly(private_project1.id) end + + context 'and using the programming language filter' do + include_context 'with language detection' + + it 'filters case-insensitively by programming language' do + get api('/projects', user), params: { with_programming_language: 'ruby' } + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |p| p['id'] }).to contain_exactly(project.id) + end + end end describe 'POST /projects/user/:id' do diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index ed0108c846a..d7ddd97e8c8 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -1584,7 +1584,7 @@ describe API::Runner, :clean_gitlab_redis_shared_state do context 'when artifacts are stored locally' do let(:download_headers) do { 'Content-Transfer-Encoding' => 'binary', - 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' } + 'Content-Disposition' => %q(attachment; filename="ci_build_artifacts.zip"; filename*=UTF-8''ci_build_artifacts.zip) } end before do diff --git a/spec/requests/user_activity_spec.rb b/spec/requests/user_activity_spec.rb new file mode 100644 index 00000000000..15666e00b9f --- /dev/null +++ b/spec/requests/user_activity_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Update of user activity' do + let(:user) { create(:user, last_activity_on: nil) } + + before do + group = create(:group, name: 'group') + project = create(:project, :public, namespace: group, name: 'project') + + create(:issue, project: project, iid: 10) + create(:merge_request, source_project: project, iid: 15) + + project.add_maintainer(user) + end + + paths_to_visit = [ + '/group', + '/group/project', + '/groups/group/-/issues', + '/groups/group/-/boards', + '/dashboard/projects', + '/dashboard/snippets', + '/dashboard/groups', + '/dashboard/todos', + '/group/project/issues', + '/group/project/issues/10', + '/group/project/merge_requests', + '/group/project/merge_requests/15' + ] + + context 'without an authenticated user' do + it 'does not set the last activity cookie' do + get "/group/project" + + expect(response.cookies['user_last_activity_on']).to be_nil + end + end + + context 'with an authenticated user' do + before do + login_as(user) + end + + context 'with a POST request' do + it 'does not set the last activity cookie' do + post "/group/project/archive" + + expect(response.cookies['user_last_activity_on']).to be_nil + end + end + + paths_to_visit.each do |path| + context "on GET to #{path}" do + it 'updates the last activity date' do + expect(Users::ActivityService).to receive(:new).and_call_original + + get path + + expect(user.last_activity_on).to eq(Date.today) + end + + context 'when calling it twice' do + it 'updates last_activity_on just once' do + expect(Users::ActivityService).to receive(:new).once.and_call_original + + 2.times do + get path + end + end + end + + context 'when last_activity_on is nil' do + before do + user.update_attribute(:last_activity_on, nil) + end + + it 'updates the last activity date' do + expect(user.last_activity_on).to be_nil + + get path + + expect(user.last_activity_on).to eq(Date.today) + end + end + + context 'when last_activity_on is stale' do + before do + user.update_attribute(:last_activity_on, 2.days.ago.to_date) + end + + it 'updates the last activity date' do + get path + + expect(user.last_activity_on).to eq(Date.today) + end + end + + context 'when last_activity_on is up to date' do + before do + user.update_attribute(:last_activity_on, Date.today) + end + + it 'does not try to update it' do + expect(Users::ActivityService).not_to receive(:new) + + get path + end + end + end + end + end +end diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index 4e64b0c9414..b46aa65818d 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -197,6 +197,24 @@ describe MergeRequests::CreateService do expect(merge_request.actual_head_pipeline).to be_merge_request end + context 'when there are no commits between source branch and target branch' do + let(:opts) do + { + title: 'Awesome merge_request', + description: 'please fix', + source_branch: 'not-merged-branch', + target_branch: 'master' + } + end + + it 'does not create a merge request pipeline' do + expect(merge_request).to be_persisted + + merge_request.reload + expect(merge_request.merge_request_pipelines.count).to eq(0) + end + end + context "when branch pipeline was created before a merge request pipline has been created" do before do create(:ci_pipeline, project: merge_request.source_project, diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 1169ed5f9f2..9e9dc5a576c 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -150,11 +150,15 @@ describe MergeRequests::RefreshService do } end - it 'create merge request pipeline' do + it 'create merge request pipeline with commits' do expect { subject } .to change { @merge_request.merge_request_pipelines.count }.by(1) .and change { @fork_merge_request.merge_request_pipelines.count }.by(1) - .and change { @another_merge_request.merge_request_pipelines.count }.by(1) + .and change { @another_merge_request.merge_request_pipelines.count }.by(0) + + expect(@merge_request.has_commits?).to be_truthy + expect(@fork_merge_request.has_commits?).to be_truthy + expect(@another_merge_request.has_commits?).to be_falsy end context "when branch pipeline was created before a merge request pipline has been created" do diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb index 458cb8f1f31..85515d548a7 100644 --- a/spec/services/preview_markdown_service_spec.rb +++ b/spec/services/preview_markdown_service_spec.rb @@ -114,23 +114,4 @@ describe PreviewMarkdownService do expect(result[:commands]).to eq 'Tags this commit to v1.2.3 with "Stable release".' 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 - - it 'honors the legacy_render parameter' do - service = described_class.new(project, user, { legacy_render: '1' }) - result = service.execute - - expect(result[:markdown_engine]).to eq :redcarpet - end end diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index f71e2b4bc24..54ce33dd103 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -16,7 +16,11 @@ describe Projects::CreateService, '#execute' do Label.create(title: "bug", template: true) project = create_project(user, opts) - expect(project.labels).not_to be_empty + created_label = project.reload.labels.last + + expect(created_label.type).to eq('ProjectLabel') + expect(created_label.project_id).to eq(project.id) + expect(created_label.title).to eq('bug') end context 'user namespace' do diff --git a/spec/services/projects/update_pages_configuration_service_spec.rb b/spec/services/projects/update_pages_configuration_service_spec.rb index e4d4e6ff3dd..7f5ef3129d7 100644 --- a/spec/services/projects/update_pages_configuration_service_spec.rb +++ b/spec/services/projects/update_pages_configuration_service_spec.rb @@ -2,23 +2,41 @@ require 'spec_helper' describe Projects::UpdatePagesConfigurationService do let(:project) { create(:project) } - subject { described_class.new(project) } + let(:service) { described_class.new(project) } describe "#update" do let(:file) { Tempfile.new('pages-test') } + subject { service.execute } + after do file.close file.unlink end - it 'updates the .update file' do - # Access this reference to ensure scoping works - Projects::Settings # rubocop:disable Lint/Void - expect(subject).to receive(:pages_config_file).and_return(file.path) - expect(subject).to receive(:reload_daemon).and_call_original + before do + allow(service).to receive(:pages_config_file).and_return(file.path) + end + + context 'when configuration changes' do + it 'updates the .update file' do + expect(service).to receive(:reload_daemon).and_call_original + + expect(subject).to include(status: :success, reload: true) + end + end + + context 'when configuration does not change' do + before do + # we set the configuration + service.execute + end + + it 'does not update the .update file' do + expect(service).not_to receive(:reload_daemon) - expect(subject.execute).to eq({ status: :success }) + expect(subject).to include(status: :success, reload: false) + end end end end diff --git a/spec/services/task_list_toggle_service_spec.rb b/spec/services/task_list_toggle_service_spec.rb index 750ac4c40ba..cc64dd25085 100644 --- a/spec/services/task_list_toggle_service_spec.rb +++ b/spec/services/task_list_toggle_service_spec.rb @@ -3,7 +3,6 @@ require 'spec_helper' describe TaskListToggleService do - let(:sourcepos) { true } let(:markdown) do <<-EOT.strip_heredoc * [ ] Task 1 @@ -40,87 +39,47 @@ describe TaskListToggleService do EOT end - shared_examples 'task lists' do - it 'checks Task 1' do - toggler = described_class.new(markdown, markdown_html, - index: 1, toggle_as_checked: true, - line_source: '* [ ] Task 1', line_number: 1, - sourcepos: sourcepos) + it 'checks Task 1' do + toggler = described_class.new(markdown, markdown_html, + toggle_as_checked: true, + line_source: '* [ ] Task 1', line_number: 1) - expect(toggler.execute).to be_truthy - expect(toggler.updated_markdown.lines[0]).to eq "* [x] Task 1\n" - expect(toggler.updated_markdown_html).to include('disabled checked> Task 1') - end - - it 'unchecks Item 1' do - toggler = described_class.new(markdown, markdown_html, - index: 3, toggle_as_checked: false, - line_source: '1. [X] Item 1', line_number: 6, - sourcepos: sourcepos) - - expect(toggler.execute).to be_truthy - expect(toggler.updated_markdown.lines[5]).to eq "1. [ ] Item 1\n" - expect(toggler.updated_markdown_html).to include('disabled> Item 1') - end - - it 'returns false if line_source does not match the text' do - toggler = described_class.new(markdown, markdown_html, - index: 2, toggle_as_checked: false, - line_source: '* [x] Task Added', line_number: 2, - sourcepos: sourcepos) - - expect(toggler.execute).to be_falsey - end + expect(toggler.execute).to be_truthy + expect(toggler.updated_markdown.lines[0]).to eq "* [x] Task 1\n" + expect(toggler.updated_markdown_html).to include('disabled checked> Task 1') + end - it 'returns false if markdown is nil' do - toggler = described_class.new(nil, markdown_html, - index: 2, toggle_as_checked: false, - line_source: '* [x] Task Added', line_number: 2, - sourcepos: sourcepos) + it 'unchecks Item 1' do + toggler = described_class.new(markdown, markdown_html, + toggle_as_checked: false, + line_source: '1. [X] Item 1', line_number: 6) - expect(toggler.execute).to be_falsey - end + expect(toggler.execute).to be_truthy + expect(toggler.updated_markdown.lines[5]).to eq "1. [ ] Item 1\n" + expect(toggler.updated_markdown_html).to include('disabled> Item 1') + end - it 'returns false if markdown_html is nil' do - toggler = described_class.new(markdown, nil, - index: 2, toggle_as_checked: false, - line_source: '* [x] Task Added', line_number: 2, - sourcepos: sourcepos) + it 'returns false if line_source does not match the text' do + toggler = described_class.new(markdown, markdown_html, + toggle_as_checked: false, + line_source: '* [x] Task Added', line_number: 2) - expect(toggler.execute).to be_falsey - end + expect(toggler.execute).to be_falsey end - context 'when using sourcepos' do - it_behaves_like 'task lists' + it 'returns false if markdown is nil' do + toggler = described_class.new(nil, markdown_html, + toggle_as_checked: false, + line_source: '* [x] Task Added', line_number: 2) + + expect(toggler.execute).to be_falsey end - context 'when using checkbox indexing' do - let(:sourcepos) { false } - let(:markdown_html) do - <<-EOT.strip_heredoc - <ul class="task-list" dir="auto"> - <li class="task-list-item"> - <input type="checkbox" class="task-list-item-checkbox" disabled> Task 1 - </li> - <li class="task-list-item"> - <input type="checkbox" class="task-list-item-checkbox" disabled checked> Task 2 - </li> - </ul> - <p dir="auto">A paragraph</p> - <ol class="task-list" dir="auto"> - <li class="task-list-item"> - <input type="checkbox" class="task-list-item-checkbox" disabled checked> Item 1 - <ul class="task-list"> - <li class="task-list-item"> - <input type="checkbox" class="task-list-item-checkbox" disabled> Sub-item 1 - </li> - </ul> - </li> - </ol> - EOT - end + it 'returns false if markdown_html is nil' do + toggler = described_class.new(markdown, nil, + toggle_as_checked: false, + line_source: '* [x] Task Added', line_number: 2) - it_behaves_like 'task lists' + expect(toggler.execute).to be_falsey end end diff --git a/spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb b/spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb index a3d31e26498..982e0317f7f 100644 --- a/spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb +++ b/spec/support/shared_examples/controllers/repository_lfs_file_load_examples.rb @@ -28,7 +28,13 @@ shared_examples 'repository lfs file load' do end it 'serves the file' do - expect(controller).to receive(:send_file).with("#{LfsObjectUploader.root}/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897", filename: filename, disposition: 'attachment') + # Notice the filename= is omitted from the disposition; this is because + # Rails 5 will append this header in send_file + expect(controller).to receive(:send_file) + .with( + "#{LfsObjectUploader.root}/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897", + filename: filename, + disposition: %Q(attachment; filename*=UTF-8''#{filename})) subject @@ -56,7 +62,7 @@ shared_examples 'repository lfs file load' do file_uri = URI.parse(response.location) params = CGI.parse(file_uri.query) - expect(params["response-content-disposition"].first).to eq "attachment;filename=\"#{filename}\"" + expect(params["response-content-disposition"].first).to eq(%q(attachment; filename="lfs_object.iso"; filename*=UTF-8''lfs_object.iso)) end end end diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index 3b8f7f5fe7d..a8fae4a88a3 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -71,21 +71,29 @@ describe 'gitlab:app namespace rake task' do end.to raise_error(SystemExit) end - it 'invokes restoration on match' do - allow(YAML).to receive(:load_file) - .and_return({ gitlab_version: gitlab_version }) - - expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:db:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:repo:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:builds:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:uploads:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:artifacts:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:pages:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:lfs:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:registry:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke) - expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout + context 'restore with matching gitlab version' do + before do + allow(YAML).to receive(:load_file) + .and_return({ gitlab_version: gitlab_version }) + expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:db:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:repo:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:builds:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:uploads:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:artifacts:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:pages:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:lfs:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:registry:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke) + end + + it 'invokes restoration on match' do + expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout + end + + it 'prints timestamps on messages' do + expect { run_rake_task('gitlab:backup:restore') }.to output(/.*\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\s[-+]\d{4}\s--\s.*/).to_stdout + end end end diff --git a/spec/workers/mail_scheduler/notification_service_worker_spec.rb b/spec/workers/mail_scheduler/notification_service_worker_spec.rb index 1033557ee88..5cfba01850c 100644 --- a/spec/workers/mail_scheduler/notification_service_worker_spec.rb +++ b/spec/workers/mail_scheduler/notification_service_worker_spec.rb @@ -9,6 +9,10 @@ describe MailScheduler::NotificationServiceWorker do ActiveJob::Arguments.serialize(args) end + def deserialize(args) + ActiveJob::Arguments.deserialize(args) + end + describe '#perform' do it 'deserializes arguments from global IDs' do expect(worker.notification_service).to receive(method).with(key) @@ -42,13 +46,48 @@ describe MailScheduler::NotificationServiceWorker do end end - describe '.perform_async' do + describe '.perform_async', :sidekiq do + around do |example| + Sidekiq::Testing.fake! { example.run } + end + it 'serializes arguments as global IDs when scheduling' do - Sidekiq::Testing.fake! do - described_class.perform_async(method, key) + described_class.perform_async(method, key) + + expect(described_class.jobs.count).to eq(1) + expect(described_class.jobs.first).to include('args' => [method, *serialize(key)]) + end + + context 'with ActiveController::Parameters' do + let(:parameters) { ActionController::Parameters.new(hash) } + + let(:hash) do + { + "nested" => { + "hash" => true + } + } + end + + context 'when permitted' do + before do + parameters.permit! + end + + it 'serializes as a serializable Hash' do + described_class.perform_async(method, parameters) - expect(described_class.jobs.count).to eq(1) - expect(described_class.jobs.first).to include('args' => [method, *serialize(key)]) + expect(described_class.jobs.count).to eq(1) + expect(deserialize(described_class.jobs.first['args'])) + .to eq([method, hash]) + end + end + + context 'when not permitted' do + it 'fails to serialize' do + expect { described_class.perform_async(method, parameters) } + .to raise_error(ActionController::UnfilteredParameters) + end end end end diff --git a/yarn.lock b/yarn.lock index ad21820fe76..423c7f75d47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -658,10 +658,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.48.0.tgz#7b2e20e357d85aa46e905e6ca51b0b4184ae2794" integrity sha512-9lRsfqN0W3JxopiXnTzvDY31O465jMTGNKpiOCXy7uAMfwZA6UsRsc7Pp369uKnOLR0duXUGOxOv4NGsK6AeXw== -"@gitlab/ui@^1.22.1": - version "1.22.1" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.22.1.tgz#92ed77216c5702776049b9ac41eb717c1acd864e" - integrity sha512-pWbEaXOOcp8Xt2TjJtPas3lXwWVvizrBOf0M8yN0XAn2GgIRCVnRMpjNEN7/oNeBcEM9CrmPYApEM/hZO+maqQ== +"@gitlab/ui@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-2.0.2.tgz#611571c931181fb783f57f712e1c2388059b301b" + integrity sha512-rUWVhWmM9EkwIEruYJEjizrQKe7TzNyKArwWY/nfEL4HptDtwbe+xHfR8IJHbpql3oI87cTO3BheMxYF6b2Ebg== dependencies: babel-standalone "^6.26.0" bootstrap-vue "^2.0.0-rc.11" @@ -3264,10 +3264,12 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" -document-register-element@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/document-register-element/-/document-register-element-1.3.0.tgz#fb3babb523c74662be47be19c6bc33e71990d940" - integrity sha1-+zurtSPHRmK+R74Zxrwz5xmQ2UA= +document-register-element@1.13.1: + version "1.13.1" + resolved "https://registry.yarnpkg.com/document-register-element/-/document-register-element-1.13.1.tgz#dad8cb7be38e04ee3f56842e6cf81af46c1249ba" + integrity sha512-92ZyLDKg9j4rOll//NNXj25f+8rAzOkYsGJonhugKwXfeqH7bzs8Ucpvey0WzZ2ZzKdrvW9RnUw3UyOZ/uhBFw== + dependencies: + lightercollective "^0.1.0" dom-serialize@^2.2.0: version "2.2.1" @@ -3416,6 +3418,11 @@ elliptic@^6.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.0" +emoji-regex@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + emoji-unicode-version@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/emoji-unicode-version/-/emoji-unicode-version-0.2.1.tgz#0ebf3666b5414097971d34994e299fce75cdbafc" |