diff options
Diffstat (limited to 'app')
219 files changed, 2489 insertions, 1503 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 0ca0e8f35dd..422becb7db8 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -100,12 +100,12 @@ const Api = { }, // Return Merge Request for project - mergeRequest(projectPath, mergeRequestId) { + mergeRequest(projectPath, mergeRequestId, params = {}) { const url = Api.buildUrl(Api.mergeRequestPath) .replace(':id', encodeURIComponent(projectPath)) .replace(':mrid', mergeRequestId); - return axios.get(url); + return axios.get(url, { params }); }, mergeRequests(params = {}) { diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index eb0985e5603..0327fceb38d 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -63,7 +63,8 @@ export default { plainDiffPath: state => state.diffs.plainDiffPath, emailPatchPath: state => state.diffs.emailPatchPath, }), - ...mapGetters(['isParallelView', 'isNotesFetched']), + ...mapGetters('diffs', ['isParallelView']), + ...mapGetters(['isNotesFetched']), targetBranch() { return { branchName: this.targetBranchName, @@ -115,7 +116,7 @@ export default { this.adjustView(); }, methods: { - ...mapActions(['setBaseConfig', 'fetchDiffFiles']), + ...mapActions('diffs', ['setBaseConfig', 'fetchDiffFiles']), fetchData() { this.fetchDiffFiles().catch(() => { createFlash(__('Something went wrong on our end. Please try again!')); diff --git a/app/assets/javascripts/diffs/components/changed_files.vue b/app/assets/javascripts/diffs/components/changed_files.vue index c5ef9fefc2f..9d29357d800 100644 --- a/app/assets/javascripts/diffs/components/changed_files.vue +++ b/app/assets/javascripts/diffs/components/changed_files.vue @@ -31,7 +31,7 @@ export default { }; }, computed: { - ...mapGetters(['isInlineView', 'isParallelView', 'areAllFilesCollapsed']), + ...mapGetters('diffs', ['isInlineView', 'isParallelView', 'areAllFilesCollapsed']), sumAddedLines() { return this.sumValues('addedLines'); }, @@ -66,7 +66,7 @@ export default { document.removeEventListener('scroll', this.handleScroll); }, methods: { - ...mapActions(['setInlineDiffViewType', 'setParallelDiffViewType', 'expandAllFiles']), + ...mapActions('diffs', ['setInlineDiffViewType', 'setParallelDiffViewType', 'expandAllFiles']), pluralize, handleScroll() { if (!this.updating) { diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index b6af49c7e2e..02d5be1821b 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -22,7 +22,7 @@ export default { projectPath: state => state.diffs.projectPath, endpoint: state => state.diffs.endpoint, }), - ...mapGetters(['isInlineView', 'isParallelView']), + ...mapGetters('diffs', ['isInlineView', 'isParallelView']), diffMode() { const diffModeKey = Object.keys(diffModes).find(key => this.diffFile[`${key}File`]); return diffModes[diffModeKey] || diffModes.replaced; @@ -39,12 +39,12 @@ export default { <div class="diff-viewer"> <template v-if="isTextFile"> <inline-diff-view - v-show="isInlineView" + v-if="isInlineView" :diff-file="diffFile" :diff-lines="diffFile.highlightedDiffLines || []" /> <parallel-diff-view - v-show="isParallelView" + v-if="isParallelView" :diff-file="diffFile" :diff-lines="diffFile.parallelDiffLines || []" /> diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index 39d535036f6..20483161033 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -15,9 +15,7 @@ export default { </script> <template> - <div - v-if="discussions.length" - > + <div> <div v-for="discussion in discussions" :key="discussion.id" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 108eefdac5f..060386c3ecb 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -47,6 +47,9 @@ export default { false, ); }, + showExpandMessage() { + return this.isCollapsed && !this.isLoadingCollapsedDiff && !this.file.tooLarge; + }, }, mounted() { document.addEventListener('scroll', this.handleScroll); @@ -55,7 +58,7 @@ export default { document.removeEventListener('scroll', this.handleScroll); }, methods: { - ...mapActions(['loadCollapsedDiff']), + ...mapActions('diffs', ['loadCollapsedDiff']), handleToggle() { const { collapsed, highlightedDiffLines, parallelDiffLines } = this.file; @@ -159,7 +162,7 @@ export default { </div> <diff-content - v-show="!isCollapsed" + v-if="!isCollapsed" :class="{ hidden: isCollapsed || file.tooLarge }" :diff-file="file" /> @@ -168,7 +171,7 @@ export default { class="diff-content loading" /> <div - v-show="isCollapsed && !isLoadingCollapsedDiff && !file.tooLarge" + v-if="showExpandMessage" class="nothing-here-block diff-collapsed" > {{ __('This diff is collapsed.') }} diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index a8e8732053b..1957698c6c1 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -145,6 +145,7 @@ export default { @click.stop="handleToggle" /> <a + v-once ref="titleWrapper" :href="titleLink" class="append-right-4" diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue index a74ea4bfaaf..ad838a32518 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -108,7 +108,7 @@ export default { }, }, methods: { - ...mapActions(['loadMoreLines', 'showCommentForm']), + ...mapActions('diffs', ['loadMoreLines', 'showCommentForm']), handleCommentButton() { this.showCommentForm({ lineCode: this.lineCode }); }, @@ -189,6 +189,7 @@ export default { </button> <a v-if="lineNumber" + v-once :data-linenumber="lineNumber" :href="lineHref" > diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 6943b462e86..db380e68bd1 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -59,7 +59,8 @@ export default { } }, methods: { - ...mapActions(['cancelCommentForm', 'saveNote', 'fetchDiscussions']), + ...mapActions('diffs', ['cancelCommentForm']), + ...mapActions(['saveNote', 'refetchDiscussionById']), handleCancelCommentForm() { this.autosave.reset(); this.cancelCommentForm({ @@ -78,10 +79,10 @@ export default { }); this.saveNote(postData) - .then(() => { + .then(result => { const endpoint = this.getNotesDataByProp('discussionsPath'); - this.fetchDiscussions(endpoint) + this.refetchDiscussionById({ path: endpoint, discussionId: result.discussion_id }) .then(() => { this.handleCancelCommentForm(); }) diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index 5b08b161114..bd02b45a63c 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -117,14 +117,6 @@ export default { <template> <td - v-if="isContentLine" - :class="lineType" - class="line_content" - v-html="normalizedLine.richText" - > - </td> - <td - v-else :class="classNameMap" > <diff-line-gutter-content diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue index 0e935f1d68e..1e8f2eecd76 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -31,22 +31,9 @@ export default { diffLineCommentForms: state => state.diffs.diffLineCommentForms, }), ...mapGetters(['discussionsByLineCode']), - isDiscussionExpanded() { - if (!this.discussions.length) { - return false; - } - - return this.discussions.every(discussion => discussion.expanded); - }, - hasCommentForm() { - return this.diffLineCommentForms[this.line.lineCode]; - }, discussions() { return this.discussionsByLineCode[this.line.lineCode] || []; }, - shouldRender() { - return this.isDiscussionExpanded || this.hasCommentForm; - }, className() { return this.discussions.length ? '' : 'js-temp-notes-holder'; }, @@ -56,7 +43,6 @@ export default { <template> <tr - v-if="shouldRender" :class="className" class="notes_holder" > @@ -67,6 +53,7 @@ export default { <td class="notes_content"> <div class="content"> <diff-discussions + v-if="discussions.length" :discussions="discussions" /> <diff-line-note-form diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index a2470843ca6..8e4715c9862 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -36,7 +36,7 @@ export default { }; }, computed: { - ...mapGetters(['isInlineView']), + ...mapGetters('diffs', ['isInlineView']), isContextLine() { return this.line.type === CONTEXT_LINE_TYPE; }, @@ -94,11 +94,12 @@ export default { :is-hover="isHover" class="diff-line-num new_line" /> - <diff-table-cell + <td + v-once :class="line.type" - :diff-file="diffFile" - :line="line" - :is-content-line="true" - /> + class="line_content" + v-html="line.richText" + > + </td> </tr> </template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index b884230fb63..9c1359f7c89 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -1,5 +1,5 @@ <script> -import { mapGetters } from 'vuex'; +import { mapGetters, mapState } from 'vuex'; import inlineDiffTableRow from './inline_diff_table_row.vue'; import inlineDiffCommentRow from './inline_diff_comment_row.vue'; import { trimFirstCharOfLineContent } from '../store/utils'; @@ -20,20 +20,33 @@ export default { }, }, computed: { - ...mapGetters(['commit']), + ...mapGetters('diffs', ['commitId']), + ...mapGetters(['discussionsByLineCode']), + ...mapState({ + diffLineCommentForms: state => state.diffs.diffLineCommentForms, + }), normalizedDiffLines() { return this.diffLines.map(line => (line.richText ? trimFirstCharOfLineContent(line) : line)); }, diffLinesLength() { return this.normalizedDiffLines.length; }, - commitId() { - return this.commit && this.commit.id; - }, userColorScheme() { return window.gon.user_color_scheme; }, }, + methods: { + shouldRenderCommentRow(line) { + if (this.diffLineCommentForms[line.lineCode]) return true; + + const lineDiscussions = this.discussionsByLineCode[line.lineCode]; + if (lineDiscussions === undefined) { + return false; + } + + return lineDiscussions.every(discussion => discussion.expanded); + }, + }, }; </script> @@ -53,6 +66,7 @@ export default { :key="line.lineCode" /> <inline-diff-comment-row + v-if="shouldRenderCommentRow(line)" :diff-file="diffFile" :diff-lines="normalizedDiffLines" :line="line" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue index 5f33ec7a3c2..1e20792b647 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -55,13 +55,6 @@ export default { hasAnyExpandedDiscussion() { return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight; }, - shouldRenderDiscussionsRow() { - const hasDiscussion = this.hasDiscussion && this.hasAnyExpandedDiscussion; - const hasCommentFormOnLeft = this.diffLineCommentForms[this.leftLineCode]; - const hasCommentFormOnRight = this.diffLineCommentForms[this.rightLineCode]; - - return hasDiscussion || hasCommentFormOnLeft || hasCommentFormOnRight; - }, shouldRenderDiscussionsOnLeft() { return this.discussionsByLineCode[this.leftLineCode] && this.hasExpandedDiscussionOnLeft; }, @@ -81,7 +74,6 @@ export default { <template> <tr - v-if="shouldRenderDiscussionsRow" :class="className" class="notes_holder" > @@ -92,6 +84,7 @@ export default { class="content" > <diff-discussions + v-if="discussionsByLineCode[leftLineCode].length" :discussions="discussionsByLineCode[leftLineCode]" /> </div> @@ -112,6 +105,7 @@ export default { class="content" > <diff-discussions + v-if="discussionsByLineCode[rightLineCode].length" :discussions="discussionsByLineCode[rightLineCode]" /> </div> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index eb769584d74..b76fc63205b 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -40,7 +40,7 @@ export default { }; }, computed: { - ...mapGetters(['isParallelView']), + ...mapGetters('diffs', ['isParallelView']), isContextLine() { return this.line.left.type === CONTEXT_LINE_TYPE; }, @@ -113,17 +113,15 @@ export default { :diff-view-type="parallelDiffViewType" class="diff-line-num old_line" /> - <diff-table-cell + <td + v-once :id="line.left.lineCode" - :diff-file="diffFile" - :line="line" - :is-content-line="true" - :line-position="linePositionLeft" - :line-type="parallelViewLeftLineType" - :diff-view-type="parallelDiffViewType" + :class="parallelViewLeftLineType" class="line_content parallel left-side" @mousedown.native="handleParallelLineMouseDown" - /> + v-html="line.left.richText" + > + </td> <diff-table-cell :diff-file="diffFile" :line="line" @@ -135,16 +133,14 @@ export default { :diff-view-type="parallelDiffViewType" class="diff-line-num new_line" /> - <diff-table-cell + <td + v-once :id="line.right.lineCode" - :diff-file="diffFile" - :line="line" - :is-content-line="true" - :line-position="linePositionRight" - :line-type="line.right.type" - :diff-view-type="parallelDiffViewType" + :class="line.right.type" class="line_content parallel right-side" @mousedown.native="handleParallelLineMouseDown" - /> + v-html="line.right.richText" + > + </td> </tr> </template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 52561e197e6..216865474a6 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -1,5 +1,5 @@ <script> -import { mapGetters } from 'vuex'; +import { mapState, mapGetters } from 'vuex'; import parallelDiffTableRow from './parallel_diff_table_row.vue'; import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; import { EMPTY_CELL_TYPE } from '../constants'; @@ -21,7 +21,11 @@ export default { }, }, computed: { - ...mapGetters(['commit']), + ...mapGetters('diffs', ['commitId']), + ...mapGetters(['discussionsByLineCode']), + ...mapState({ + diffLineCommentForms: state => state.diffs.diffLineCommentForms, + }), parallelDiffLines() { return this.diffLines.map(line => { const parallelLine = Object.assign({}, line); @@ -44,13 +48,36 @@ export default { diffLinesLength() { return this.parallelDiffLines.length; }, - commitId() { - return this.commit && this.commit.id; - }, userColorScheme() { return window.gon.user_color_scheme; }, }, + methods: { + shouldRenderCommentRow(line) { + const leftLineCode = line.left.lineCode; + const rightLineCode = line.right.lineCode; + const discussions = this.discussionsByLineCode; + const leftDiscussions = discussions[leftLineCode]; + const rightDiscussions = discussions[rightLineCode]; + const hasDiscussion = leftDiscussions || rightDiscussions; + + const hasExpandedDiscussionOnLeft = leftDiscussions + ? leftDiscussions.every(discussion => discussion.expanded) + : false; + const hasExpandedDiscussionOnRight = rightDiscussions + ? rightDiscussions.every(discussion => discussion.expanded) + : false; + + if (hasDiscussion && (hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight)) { + return true; + } + + const hasCommentFormOnLeft = this.diffLineCommentForms[leftLineCode]; + const hasCommentFormOnRight = this.diffLineCommentForms[rightLineCode]; + + return hasCommentFormOnLeft || hasCommentFormOnRight; + }, + }, }; </script> @@ -72,6 +99,7 @@ export default { :key="index" /> <parallel-diff-comment-row + v-if="shouldRenderCommentRow(line)" :key="line.left.lineCode || line.right.lineCode" :line="line" :diff-file="diffFile" diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 66d0f47d102..f3c2d7427e7 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -1,16 +1,12 @@ import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants'; -export default { - isParallelView(state) { - return state.diffViewType === PARALLEL_DIFF_VIEW_TYPE; - }, - isInlineView(state) { - return state.diffViewType === INLINE_DIFF_VIEW_TYPE; - }, - areAllFilesCollapsed(state) { - return state.diffFiles.every(file => file.collapsed); - }, - commit(state) { - return state.commit; - }, -}; +export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW_TYPE; + +export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE; + +export const areAllFilesCollapsed = state => state.diffFiles.every(file => file.collapsed); + +export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js new file mode 100644 index 00000000000..39d90a64aab --- /dev/null +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -0,0 +1,18 @@ +import Cookies from 'js-cookie'; +import { getParameterValues } from '~/lib/utils/url_utility'; +import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; + +const viewTypeFromQueryString = getParameterValues('view')[0]; +const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); +const defaultViewType = INLINE_DIFF_VIEW_TYPE; + +export default () => ({ + isLoading: true, + endpoint: '', + basePath: '', + commit: null, + diffFiles: [], + mergeRequestDiffs: [], + diffLineCommentForms: {}, + diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, +}); diff --git a/app/assets/javascripts/diffs/store/modules/index.js b/app/assets/javascripts/diffs/store/modules/index.js index 94caa131506..90505f83b60 100644 --- a/app/assets/javascripts/diffs/store/modules/index.js +++ b/app/assets/javascripts/diffs/store/modules/index.js @@ -1,25 +1,11 @@ -import Cookies from 'js-cookie'; -import { getParameterValues } from '~/lib/utils/url_utility'; import actions from '../actions'; -import getters from '../getters'; +import * as getters from '../getters'; import mutations from '../mutations'; -import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; - -const viewTypeFromQueryString = getParameterValues('view')[0]; -const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); -const defaultViewType = INLINE_DIFF_VIEW_TYPE; +import createState from './diff_state'; export default { - state: { - isLoading: true, - endpoint: '', - basePath: '', - commit: null, - diffFiles: [], - mergeRequestDiffs: [], - diffLineCommentForms: {}, - diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, - }, + namespaced: true, + state: createState(), getters, actions, mutations, diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 8aa8a114c6f..a98b2be89a3 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -66,15 +66,10 @@ export default { }, [types.EXPAND_ALL_FILES](state) { - const diffFiles = []; - - state.diffFiles.forEach(file => { - diffFiles.push({ - ...file, - collapsed: false, - }); - }); - - Object.assign(state, { diffFiles }); + // eslint-disable-next-line no-param-reassign + state.diffFiles = state.diffFiles.map(file => ({ + ...file, + collapsed: false, + })); }, }; diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index 17ea3bdb179..8abd8bc581a 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -171,6 +171,8 @@ export default class DueDateSelectors { initMilestoneDatePicker() { $('.datepicker').each(function initPikadayMilestone() { const $datePicker = $(this); + const datePickerVal = $datePicker.val(); + const calendar = new Pikaday({ field: $datePicker.get(0), theme: 'gitlab-theme animate-picker', @@ -183,7 +185,7 @@ export default class DueDateSelectors { }, }); - calendar.setDate(parsePikadayDate($datePicker.val())); + calendar.setDate(parsePikadayDate(datePickerVal)); $datePicker.data('pikaday', calendar); }); diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue new file mode 100644 index 00000000000..2f030de8967 --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -0,0 +1,122 @@ +<script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import AccessorUtilities from '~/lib/utils/accessor'; +import eventHub from '../event_hub'; +import store from '../store/'; +import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants'; +import { isMobile, updateExistingFrequentItem } from '../utils'; +import FrequentItemsSearchInput from './frequent_items_search_input.vue'; +import FrequentItemsList from './frequent_items_list.vue'; +import frequentItemsMixin from './frequent_items_mixin'; + +export default { + store, + components: { + LoadingIcon, + FrequentItemsSearchInput, + FrequentItemsList, + }, + mixins: [frequentItemsMixin], + props: { + currentUserName: { + type: String, + required: true, + }, + currentItem: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']), + ...mapGetters(['hasSearchQuery']), + translations() { + return this.getTranslations(['loadingMessage', 'header']); + }, + }, + created() { + const { namespace, currentUserName, currentItem } = this; + const storageKey = `${currentUserName}/${STORAGE_KEY[namespace]}`; + + this.setNamespace(namespace); + this.setStorageKey(storageKey); + + if (currentItem.id) { + this.logItemAccess(storageKey, currentItem); + } + + eventHub.$on(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); + }, + beforeDestroy() { + eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); + }, + methods: { + ...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']), + dropdownOpenHandler() { + if (this.searchQuery === '' || isMobile()) { + this.fetchFrequentItems(); + } + }, + logItemAccess(storageKey, item) { + if (!AccessorUtilities.isLocalStorageAccessSafe()) { + return false; + } + + // Check if there's any frequent items list set + const storedRawItems = localStorage.getItem(storageKey); + const storedFrequentItems = storedRawItems + ? JSON.parse(storedRawItems) + : [{ ...item, frequency: 1 }]; // No frequent items list set, set one up. + + // Check if item already exists in list + const itemMatchIndex = storedFrequentItems.findIndex( + frequentItem => frequentItem.id === item.id, + ); + + if (itemMatchIndex > -1) { + storedFrequentItems[itemMatchIndex] = updateExistingFrequentItem( + storedFrequentItems[itemMatchIndex], + item, + ); + } else { + if (storedFrequentItems.length === FREQUENT_ITEMS.MAX_COUNT) { + storedFrequentItems.shift(); + } + + storedFrequentItems.push({ ...item, frequency: 1 }); + } + + return localStorage.setItem(storageKey, JSON.stringify(storedFrequentItems)); + }, + }, +}; +</script> + +<template> + <div> + <frequent-items-search-input + :namespace="namespace" + /> + <loading-icon + v-if="isLoadingItems" + :label="translations.loadingMessage" + class="loading-animation prepend-top-20" + size="2" + /> + <div + v-if="!isLoadingItems && !hasSearchQuery" + class="section-header" + > + {{ translations.header }} + </div> + <frequent-items-list + v-if="!isLoadingItems" + :items="items" + :namespace="namespace" + :has-search-query="hasSearchQuery" + :is-fetch-failed="isFetchFailed" + :matcher="searchQuery" + /> + </div> +</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue new file mode 100644 index 00000000000..8e511aa2a36 --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue @@ -0,0 +1,78 @@ +<script> +import FrequentItemsListItem from './frequent_items_list_item.vue'; +import frequentItemsMixin from './frequent_items_mixin'; + +export default { + components: { + FrequentItemsListItem, + }, + mixins: [frequentItemsMixin], + props: { + items: { + type: Array, + required: true, + }, + hasSearchQuery: { + type: Boolean, + required: true, + }, + isFetchFailed: { + type: Boolean, + required: true, + }, + matcher: { + type: String, + required: true, + }, + }, + computed: { + translations() { + return this.getTranslations([ + 'itemListEmptyMessage', + 'itemListErrorMessage', + 'searchListEmptyMessage', + 'searchListErrorMessage', + ]); + }, + isListEmpty() { + return this.items.length === 0; + }, + listEmptyMessage() { + if (this.hasSearchQuery) { + return this.isFetchFailed + ? this.translations.searchListErrorMessage + : this.translations.searchListEmptyMessage; + } + + return this.isFetchFailed + ? this.translations.itemListErrorMessage + : this.translations.itemListEmptyMessage; + }, + }, +}; +</script> + +<template> + <div class="frequent-items-list-container"> + <ul class="list-unstyled"> + <li + v-if="isListEmpty" + :class="{ 'section-failure': isFetchFailed }" + class="section-empty" + > + {{ listEmptyMessage }} + </li> + <frequent-items-list-item + v-for="item in items" + v-else + :key="item.id" + :item-id="item.id" + :item-name="item.name" + :namespace="item.namespace" + :web-url="item.webUrl" + :avatar-url="item.avatarUrl" + :matcher="matcher" + /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue new file mode 100644 index 00000000000..1f1665ff7fe --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -0,0 +1,117 @@ +<script> +/* eslint-disable vue/require-default-prop, vue/require-prop-types */ +import Identicon from '../../vue_shared/components/identicon.vue'; + +export default { + components: { + Identicon, + }, + props: { + matcher: { + type: String, + required: false, + }, + itemId: { + type: Number, + required: true, + }, + itemName: { + type: String, + required: true, + }, + namespace: { + type: String, + required: false, + }, + webUrl: { + type: String, + required: true, + }, + avatarUrl: { + required: true, + validator(value) { + return value === null || typeof value === 'string'; + }, + }, + }, + computed: { + hasAvatar() { + return this.avatarUrl !== null; + }, + highlightedItemName() { + if (this.matcher) { + const matcherRegEx = new RegExp(this.matcher, 'gi'); + const matches = this.itemName.match(matcherRegEx); + + if (matches && matches.length > 0) { + return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`); + } + } + return this.itemName; + }, + /** + * Smartly truncates item namespace by doing two things; + * 1. Only include Group names in path by removing item name + * 2. Only include first and last group names in the path + * when namespace has more than 2 groups present + * + * First part (removal of item name from namespace) can be + * done from backend but doing so involves migration of + * existing item namespaces which is not wise thing to do. + */ + truncatedNamespace() { + if (!this.namespace) { + return null; + } + const namespaceArr = this.namespace.split(' / '); + + namespaceArr.splice(-1, 1); + let namespace = namespaceArr.join(' / '); + + if (namespaceArr.length > 2) { + namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`; + } + + return namespace; + }, + }, +}; +</script> + +<template> + <li class="frequent-items-list-item-container"> + <a + :href="webUrl" + class="clearfix" + > + <div class="frequent-items-item-avatar-container"> + <img + v-if="hasAvatar" + :src="avatarUrl" + class="avatar s32" + /> + <identicon + v-else + :entity-id="itemId" + :entity-name="itemName" + size-class="s32" + /> + </div> + <div class="frequent-items-item-metadata-container"> + <div + :title="itemName" + class="frequent-items-item-title" + v-html="highlightedItemName" + > + </div> + <div + v-if="truncatedNamespace" + :title="namespace" + class="frequent-items-item-namespace" + > + {{ truncatedNamespace }} + </div> + </div> + </a> + </li> +</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js b/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js new file mode 100644 index 00000000000..704dc83ca8e --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js @@ -0,0 +1,23 @@ +import { TRANSLATION_KEYS } from '../constants'; + +export default { + props: { + namespace: { + type: String, + required: true, + }, + }, + methods: { + getTranslations(keys) { + const translationStrings = keys.reduce( + (acc, key) => ({ + ...acc, + [key]: TRANSLATION_KEYS[this.namespace][key], + }), + {}, + ); + + return translationStrings; + }, + }, +}; diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue new file mode 100644 index 00000000000..a6a265eb3fd --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue @@ -0,0 +1,55 @@ +<script> +import _ from 'underscore'; +import { mapActions } from 'vuex'; +import eventHub from '../event_hub'; +import frequentItemsMixin from './frequent_items_mixin'; + +export default { + mixins: [frequentItemsMixin], + data() { + return { + searchQuery: '', + }; + }, + computed: { + translations() { + return this.getTranslations(['searchInputPlaceholder']); + }, + }, + watch: { + searchQuery: _.debounce(function debounceSearchQuery() { + this.setSearchQuery(this.searchQuery); + }, 500), + }, + mounted() { + eventHub.$on(`${this.namespace}-dropdownOpen`, this.setFocus); + }, + beforeDestroy() { + eventHub.$off(`${this.namespace}-dropdownOpen`, this.setFocus); + }, + methods: { + ...mapActions(['setSearchQuery']), + setFocus() { + this.$refs.search.focus(); + }, + }, +}; +</script> + +<template> + <div class="search-input-container d-none d-sm-block"> + <input + ref="search" + v-model="searchQuery" + :placeholder="translations.searchInputPlaceholder" + type="search" + class="form-control" + /> + <i + v-if="!searchQuery" + class="search-icon fa fa-fw fa-search" + aria-hidden="true" + > + </i> + </div> +</template> diff --git a/app/assets/javascripts/frequent_items/constants.js b/app/assets/javascripts/frequent_items/constants.js new file mode 100644 index 00000000000..9bc17f5ef4f --- /dev/null +++ b/app/assets/javascripts/frequent_items/constants.js @@ -0,0 +1,38 @@ +import { s__ } from '~/locale'; + +export const FREQUENT_ITEMS = { + MAX_COUNT: 20, + LIST_COUNT_DESKTOP: 5, + LIST_COUNT_MOBILE: 3, + ELIGIBLE_FREQUENCY: 3, +}; + +export const HOUR_IN_MS = 3600000; + +export const STORAGE_KEY = { + projects: 'frequent-projects', + groups: 'frequent-groups', +}; + +export const TRANSLATION_KEYS = { + projects: { + loadingMessage: s__('ProjectsDropdown|Loading projects'), + header: s__('ProjectsDropdown|Frequently visited'), + itemListErrorMessage: s__( + 'ProjectsDropdown|This feature requires browser localStorage support', + ), + itemListEmptyMessage: s__('ProjectsDropdown|Projects you visit often will appear here'), + searchListErrorMessage: s__('ProjectsDropdown|Something went wrong on our end.'), + searchListEmptyMessage: s__('ProjectsDropdown|Sorry, no projects matched your search'), + searchInputPlaceholder: s__('ProjectsDropdown|Search your projects'), + }, + groups: { + loadingMessage: s__('GroupsDropdown|Loading groups'), + header: s__('GroupsDropdown|Frequently visited'), + itemListErrorMessage: s__('GroupsDropdown|This feature requires browser localStorage support'), + itemListEmptyMessage: s__('GroupsDropdown|Groups you visit often will appear here'), + searchListErrorMessage: s__('GroupsDropdown|Something went wrong on our end.'), + searchListEmptyMessage: s__('GroupsDropdown|Sorry, no groups matched your search'), + searchInputPlaceholder: s__('GroupsDropdown|Search your groups'), + }, +}; diff --git a/app/assets/javascripts/projects_dropdown/event_hub.js b/app/assets/javascripts/frequent_items/event_hub.js index 0948c2e5352..0948c2e5352 100644 --- a/app/assets/javascripts/projects_dropdown/event_hub.js +++ b/app/assets/javascripts/frequent_items/event_hub.js diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js new file mode 100644 index 00000000000..5157ff211dc --- /dev/null +++ b/app/assets/javascripts/frequent_items/index.js @@ -0,0 +1,69 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import eventHub from '~/frequent_items/event_hub'; +import frequentItems from './components/app.vue'; + +Vue.use(Translate); + +const frequentItemDropdowns = [ + { + namespace: 'projects', + key: 'project', + }, + { + namespace: 'groups', + key: 'group', + }, +]; + +document.addEventListener('DOMContentLoaded', () => { + frequentItemDropdowns.forEach(dropdown => { + const { namespace, key } = dropdown; + const el = document.getElementById(`js-${namespace}-dropdown`); + const navEl = document.getElementById(`nav-${namespace}-dropdown`); + + // Don't do anything if element doesn't exist (No groups dropdown) + // This is for when the user accesses GitLab without logging in + if (!el || !navEl) { + return; + } + + $(navEl).on('shown.bs.dropdown', () => { + eventHub.$emit(`${namespace}-dropdownOpen`); + }); + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + frequentItems, + }, + data() { + const { dataset } = this.$options.el; + const item = { + id: Number(dataset[`${key}Id`]), + name: dataset[`${key}Name`], + namespace: dataset[`${key}Namespace`], + webUrl: dataset[`${key}WebUrl`], + avatarUrl: dataset[`${key}AvatarUrl`] || null, + lastAccessedOn: Date.now(), + }; + + return { + currentUserName: dataset.userName, + currentItem: item, + }; + }, + render(createElement) { + return createElement('frequent-items', { + props: { + namespace, + currentUserName: this.currentUserName, + currentItem: this.currentItem, + }, + }); + }, + }); + }); +}); diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js new file mode 100644 index 00000000000..3dd89a82a42 --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/actions.js @@ -0,0 +1,81 @@ +import Api from '~/api'; +import AccessorUtilities from '~/lib/utils/accessor'; +import * as types from './mutation_types'; +import { getTopFrequentItems } from '../utils'; + +export const setNamespace = ({ commit }, namespace) => { + commit(types.SET_NAMESPACE, namespace); +}; + +export const setStorageKey = ({ commit }, key) => { + commit(types.SET_STORAGE_KEY, key); +}; + +export const requestFrequentItems = ({ commit }) => { + commit(types.REQUEST_FREQUENT_ITEMS); +}; +export const receiveFrequentItemsSuccess = ({ commit }, data) => { + commit(types.RECEIVE_FREQUENT_ITEMS_SUCCESS, data); +}; +export const receiveFrequentItemsError = ({ commit }) => { + commit(types.RECEIVE_FREQUENT_ITEMS_ERROR); +}; + +export const fetchFrequentItems = ({ state, dispatch }) => { + dispatch('requestFrequentItems'); + + if (AccessorUtilities.isLocalStorageAccessSafe()) { + const storedFrequentItems = JSON.parse(localStorage.getItem(state.storageKey)); + + dispatch( + 'receiveFrequentItemsSuccess', + !storedFrequentItems ? [] : getTopFrequentItems(storedFrequentItems), + ); + } else { + dispatch('receiveFrequentItemsError'); + } +}; + +export const requestSearchedItems = ({ commit }) => { + commit(types.REQUEST_SEARCHED_ITEMS); +}; +export const receiveSearchedItemsSuccess = ({ commit }, data) => { + commit(types.RECEIVE_SEARCHED_ITEMS_SUCCESS, data); +}; +export const receiveSearchedItemsError = ({ commit }) => { + commit(types.RECEIVE_SEARCHED_ITEMS_ERROR); +}; +export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => { + dispatch('requestSearchedItems'); + + const params = { + simple: true, + per_page: 20, + membership: !!gon.current_user_id, + }; + + if (state.namespace === 'projects') { + params.order_by = 'last_activity_at'; + } + + return Api[state.namespace](searchQuery, params) + .then(results => { + dispatch('receiveSearchedItemsSuccess', results); + }) + .catch(() => { + dispatch('receiveSearchedItemsError'); + }); +}; + +export const setSearchQuery = ({ commit, dispatch }, query) => { + commit(types.SET_SEARCH_QUERY, query); + + if (query) { + dispatch('fetchSearchedItems', query); + } else { + dispatch('fetchFrequentItems'); + } +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/frequent_items/store/getters.js b/app/assets/javascripts/frequent_items/store/getters.js new file mode 100644 index 00000000000..00165db6684 --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/getters.js @@ -0,0 +1,4 @@ +export const hasSearchQuery = state => state.searchQuery !== ''; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/frequent_items/store/index.js b/app/assets/javascripts/frequent_items/store/index.js new file mode 100644 index 00000000000..ece9e6419dd --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + getters, + mutations, + state: state(), + }); diff --git a/app/assets/javascripts/frequent_items/store/mutation_types.js b/app/assets/javascripts/frequent_items/store/mutation_types.js new file mode 100644 index 00000000000..cbe2c9401ad --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/mutation_types.js @@ -0,0 +1,9 @@ +export const SET_NAMESPACE = 'SET_NAMESPACE'; +export const SET_STORAGE_KEY = 'SET_STORAGE_KEY'; +export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; +export const REQUEST_FREQUENT_ITEMS = 'REQUEST_FREQUENT_ITEMS'; +export const RECEIVE_FREQUENT_ITEMS_SUCCESS = 'RECEIVE_FREQUENT_ITEMS_SUCCESS'; +export const RECEIVE_FREQUENT_ITEMS_ERROR = 'RECEIVE_FREQUENT_ITEMS_ERROR'; +export const REQUEST_SEARCHED_ITEMS = 'REQUEST_SEARCHED_ITEMS'; +export const RECEIVE_SEARCHED_ITEMS_SUCCESS = 'RECEIVE_SEARCHED_ITEMS_SUCCESS'; +export const RECEIVE_SEARCHED_ITEMS_ERROR = 'RECEIVE_SEARCHED_ITEMS_ERROR'; diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js new file mode 100644 index 00000000000..41b660a243f --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/mutations.js @@ -0,0 +1,71 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_NAMESPACE](state, namespace) { + Object.assign(state, { + namespace, + }); + }, + [types.SET_STORAGE_KEY](state, storageKey) { + Object.assign(state, { + storageKey, + }); + }, + [types.SET_SEARCH_QUERY](state, searchQuery) { + const hasSearchQuery = searchQuery !== ''; + + Object.assign(state, { + searchQuery, + isLoadingItems: true, + hasSearchQuery, + }); + }, + [types.REQUEST_FREQUENT_ITEMS](state) { + Object.assign(state, { + isLoadingItems: true, + hasSearchQuery: false, + }); + }, + [types.RECEIVE_FREQUENT_ITEMS_SUCCESS](state, rawItems) { + Object.assign(state, { + items: rawItems, + isLoadingItems: false, + hasSearchQuery: false, + isFetchFailed: false, + }); + }, + [types.RECEIVE_FREQUENT_ITEMS_ERROR](state) { + Object.assign(state, { + isLoadingItems: false, + hasSearchQuery: false, + isFetchFailed: true, + }); + }, + [types.REQUEST_SEARCHED_ITEMS](state) { + Object.assign(state, { + isLoadingItems: true, + hasSearchQuery: true, + }); + }, + [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) { + Object.assign(state, { + items: rawItems.map(rawItem => ({ + id: rawItem.id, + name: rawItem.name, + namespace: rawItem.name_with_namespace || rawItem.full_name, + webUrl: rawItem.web_url, + avatarUrl: rawItem.avatar_url, + })), + isLoadingItems: false, + hasSearchQuery: true, + isFetchFailed: false, + }); + }, + [types.RECEIVE_SEARCHED_ITEMS_ERROR](state) { + Object.assign(state, { + isLoadingItems: false, + hasSearchQuery: true, + isFetchFailed: true, + }); + }, +}; diff --git a/app/assets/javascripts/frequent_items/store/state.js b/app/assets/javascripts/frequent_items/store/state.js new file mode 100644 index 00000000000..75b04febee4 --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/state.js @@ -0,0 +1,8 @@ +export default () => ({ + namespace: '', + storageKey: '', + searchQuery: '', + isLoadingItems: false, + isFetchFailed: false, + items: [], +}); diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js new file mode 100644 index 00000000000..aba692e4b99 --- /dev/null +++ b/app/assets/javascripts/frequent_items/utils.js @@ -0,0 +1,49 @@ +import _ from 'underscore'; +import bp from '~/breakpoints'; +import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants'; + +export const isMobile = () => { + const screenSize = bp.getBreakpointSize(); + + return screenSize === 'sm' || screenSize === 'xs'; +}; + +export const getTopFrequentItems = items => { + if (!items) { + return []; + } + const frequentItemsCount = isMobile() + ? FREQUENT_ITEMS.LIST_COUNT_MOBILE + : FREQUENT_ITEMS.LIST_COUNT_DESKTOP; + + const frequentItems = items.filter(item => item.frequency >= FREQUENT_ITEMS.ELIGIBLE_FREQUENCY); + + if (!frequentItems || frequentItems.length === 0) { + return []; + } + + frequentItems.sort((itemA, itemB) => { + // Sort all frequent items in decending order of frequency + // and then by lastAccessedOn with recent most first + if (itemA.frequency !== itemB.frequency) { + return itemB.frequency - itemA.frequency; + } else if (itemA.lastAccessedOn !== itemB.lastAccessedOn) { + return itemB.lastAccessedOn - itemA.lastAccessedOn; + } + + return 0; + }); + + return _.first(frequentItems, frequentItemsCount); +}; + +export const updateExistingFrequentItem = (frequentItem, item) => { + const accessedOverHourAgo = + Math.abs(item.lastAccessedOn - frequentItem.lastAccessedOn) / HOUR_IN_MS > 1; + + return { + ...item, + frequency: accessedOverHourAgo ? frequentItem.frequency + 1 : frequentItem.frequency, + lastAccessedOn: accessedOverHourAgo ? Date.now() : frequentItem.lastAccessedOn, + }; +}; diff --git a/app/assets/javascripts/ide/components/merge_requests/info.vue b/app/assets/javascripts/ide/components/merge_requests/info.vue new file mode 100644 index 00000000000..199d2e74971 --- /dev/null +++ b/app/assets/javascripts/ide/components/merge_requests/info.vue @@ -0,0 +1,43 @@ +<script> +import { mapGetters } from 'vuex'; +import Icon from '../../../vue_shared/components/icon.vue'; +import TitleComponent from '../../../issue_show/components/title.vue'; +import DescriptionComponent from '../../../issue_show/components/description.vue'; + +export default { + components: { + Icon, + TitleComponent, + DescriptionComponent, + }, + computed: { + ...mapGetters(['currentMergeRequest']), + }, +}; +</script> + +<template> + <div class="ide-merge-request-info h-100 d-flex flex-column"> + <div class="detail-page-header"> + <icon + name="git-merge" + class="align-self-center append-right-8" + /> + <strong> + !{{ currentMergeRequest.iid }} + </strong> + </div> + <div class="issuable-details"> + <title-component + :issuable-ref="currentMergeRequest.iid" + :title-html="currentMergeRequest.title_html" + :title-text="currentMergeRequest.title" + /> + <description-component + :description-html="currentMergeRequest.description_html" + :description-text="currentMergeRequest.description" + :can-update="false" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index 5cd2c9ce188..e4a5fcc67c4 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -5,6 +5,7 @@ import Icon from '../../../vue_shared/components/icon.vue'; import { rightSidebarViews } from '../../constants'; import PipelinesList from '../pipelines/list.vue'; import JobsDetail from '../jobs/detail.vue'; +import MergeRequestInfo from '../merge_requests/info.vue'; import ResizablePanel from '../resizable_panel.vue'; export default { @@ -16,9 +17,10 @@ export default { PipelinesList, JobsDetail, ResizablePanel, + MergeRequestInfo, }, computed: { - ...mapState(['rightPane']), + ...mapState(['rightPane', 'currentMergeRequestId']), pipelinesActive() { return ( this.rightPane === rightSidebarViews.pipelines || @@ -54,10 +56,33 @@ export default { </resizable-panel> <nav class="ide-activity-bar"> <ul class="list-unstyled"> + <li + v-if="currentMergeRequestId" + > + <button + v-tooltip + :title="__('Merge Request')" + :aria-label="__('Merge Request')" + :class="{ + active: rightPane === $options.rightSidebarViews.mergeRequestInfo + }" + data-container="body" + data-placement="left" + class="ide-sidebar-link is-right" + type="button" + @click="clickTab($event, $options.rightSidebarViews.mergeRequestInfo)" + > + <icon + :size="16" + name="text-description" + /> + </button> + </li> <li> <button v-tooltip :title="__('Pipelines')" + :aria-label="__('Pipelines')" :class="{ active: pipelinesActive }" diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 12e0c3aeef0..45d36f6f42c 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -31,6 +31,7 @@ export const diffModes = { export const rightSidebarViews = { pipelines: 'pipelines-list', jobsDetail: 'jobs-detail', + mergeRequestInfo: 'merge-request-info', }; export const stageKeys = { diff --git a/app/assets/javascripts/ide/lib/themes/gl_theme.js b/app/assets/javascripts/ide/lib/themes/gl_theme.js index 2fc96250c7d..439ae50448a 100644 --- a/app/assets/javascripts/ide/lib/themes/gl_theme.js +++ b/app/assets/javascripts/ide/lib/themes/gl_theme.js @@ -9,6 +9,7 @@ export default { 'diffEditor.insertedTextBackground': '#ddfbe6', 'diffEditor.removedTextBackground': '#f9d7dc', 'editor.selectionBackground': '#aad6f8', + 'editorIndentGuide.activeBackground': '#cccccc', }, }, }; diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 3e939f0c1a3..49a481f25d5 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -40,8 +40,8 @@ export default { getProjectData(namespace, project) { return Api.project(`${namespace}/${project}`); }, - getProjectMergeRequestData(projectId, mergeRequestId) { - return Api.mergeRequest(projectId, mergeRequestId); + getProjectMergeRequestData(projectId, mergeRequestId, params = {}) { + return Api.mergeRequest(projectId, mergeRequestId, params); }, getProjectMergeRequestChanges(projectId, mergeRequestId) { return Api.mergeRequestChanges(projectId, mergeRequestId); diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 4aa151abcb7..6bdf9dc3028 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -9,7 +9,7 @@ export const getMergeRequestData = ( new Promise((resolve, reject) => { if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) { service - .getProjectMergeRequestData(projectId, mergeRequestId) + .getProjectMergeRequestData(projectId, mergeRequestId, { render_html: true }) .then(({ data }) => { commit(types.SET_MERGE_REQUEST, { projectPath: projectId, diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index b6364318537..ad928484952 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -108,6 +108,11 @@ type: String, required: true, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, projectPath: { type: String, required: true, @@ -282,6 +287,7 @@ :issuable-templates="issuableTemplates" :markdown-docs-path="markdownDocsPath" :markdown-preview-path="markdownPreviewPath" + :markdown-version="markdownVersion" :project-path="projectPath" :project-namespace="projectNamespace" :show-delete-button="showDeleteButton" diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 5f58f671c73..97acc5ba385 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -20,6 +20,11 @@ type: String, required: true, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, canAttachFile: { type: Boolean, required: false, @@ -47,6 +52,7 @@ <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" + :markdown-version="markdownVersion" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" > diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index 5bfc072e3da..e509bb52f7d 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -35,6 +35,11 @@ type: String, required: true, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, projectPath: { type: String, required: true, @@ -97,6 +102,7 @@ :form-state="formState" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" + :markdown-version="markdownVersion" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" /> diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index 12101c0daa5..b5e8e0ea44b 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -1,67 +1,67 @@ <script> - import animateMixin from '../mixins/animate'; - import eventHub from '../event_hub'; - import tooltip from '../../vue_shared/directives/tooltip'; - import { spriteIcon } from '../../lib/utils/common_utils'; +import animateMixin from '../mixins/animate'; +import eventHub from '../event_hub'; +import tooltip from '../../vue_shared/directives/tooltip'; +import { spriteIcon } from '../../lib/utils/common_utils'; - export default { - directives: { - tooltip, +export default { + directives: { + tooltip, + }, + mixins: [animateMixin], + props: { + issuableRef: { + type: [String, Number], + required: true, }, - mixins: [animateMixin], - props: { - issuableRef: { - type: String, - required: true, - }, - canUpdate: { - required: false, - type: Boolean, - default: false, - }, - titleHtml: { - type: String, - required: true, - }, - titleText: { - type: String, - required: true, - }, - showInlineEditButton: { - type: Boolean, - required: false, - default: false, - }, + canUpdate: { + required: false, + type: Boolean, + default: false, }, - data() { - return { - preAnimation: false, - pulseAnimation: false, - titleEl: document.querySelector('title'), - }; + titleHtml: { + type: String, + required: true, }, - computed: { - pencilIcon() { - return spriteIcon('pencil', 'link-highlight'); - }, + titleText: { + type: String, + required: true, }, - watch: { - titleHtml() { - this.setPageTitle(); - this.animateChange(); - }, + showInlineEditButton: { + type: Boolean, + required: false, + default: false, }, - methods: { - setPageTitle() { - const currentPageTitleScope = this.titleEl.innerText.split('·'); - currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; - this.titleEl.textContent = currentPageTitleScope.join('·'); - }, - edit() { - eventHub.$emit('open.form'); - }, + }, + data() { + return { + preAnimation: false, + pulseAnimation: false, + titleEl: document.querySelector('title'), + }; + }, + computed: { + pencilIcon() { + return spriteIcon('pencil', 'link-highlight'); }, - }; + }, + watch: { + titleHtml() { + this.setPageTitle(); + this.animateChange(); + }, + }, + methods: { + setPageTitle() { + const currentPageTitleScope = this.titleEl.innerText.split('·'); + currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; + this.titleEl.textContent = currentPageTitleScope.join('·'); + }, + edit() { + eventHub.$emit('open.form'); + }, + }, +}; </script> <template> diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index c9ce838cd48..2718f73a830 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -26,7 +26,7 @@ import './feature_highlight/feature_highlight_options'; import LazyLoader from './lazy_loader'; import initLogoAnimation from './logo'; import './milestone_select'; -import './projects_dropdown'; +import './frequent_items'; import initBreadcrumbs from './breadcrumb'; import initDispatcher from './dispatcher'; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 48cda28a1ae..8124ae6201f 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1251,13 +1251,15 @@ export default class Notes { var postUrl = $originalContentEl.data('postUrl'); var targetId = $originalContentEl.data('targetId'); var targetType = $originalContentEl.data('targetType'); + var markdownVersion = $originalContentEl.data('markdownVersion'); this.glForm = new GLForm($editForm.find('form'), this.enableGFM); $editForm .find('form') .attr('action', `${postUrl}?html=true`) - .attr('data-remote', 'true'); + .attr('data-remote', 'true') + .attr('data-markdown-version', markdownVersion); $editForm.find('.js-form-target-id').val(targetId); $editForm.find('.js-form-target-type').val(targetType); $editForm diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index c6a524f68cb..6612bc44e0b 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -34,6 +34,11 @@ export default { type: String, required: true, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -344,6 +349,7 @@ Please check your network connection and try again.`; :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" + :markdown-version="markdownVersion" :add-spacing-classes="false"> <textarea id="note-body" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index d2db68df98e..6f4a0709825 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -92,6 +92,7 @@ export default { :is-editing="isEditing" :note-body="noteBody" :note-id="note.id" + :markdown-version="note.cached_markdown_version" @handleFormUpdate="handleFormUpdate" @cancelForm="formCancelHandler" /> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index a4e3faa5d75..963e3a37b39 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -24,6 +24,11 @@ export default { required: false, default: 0, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, saveButtonTitle: { type: String, required: false, @@ -156,6 +161,7 @@ export default { <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" + :markdown-version="markdownVersion" :quick-actions-docs-path="quickActionsDocsPath" :add-spacing-classes="false"> <textarea diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index a8995021699..9b8713b40fb 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -43,6 +43,11 @@ export default { required: false, default: true, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -192,6 +197,7 @@ export default { <comment-form :noteable-type="noteableType" + :markdown-version="markdownVersion" /> </div> </template> diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index eed3a82854d..6dd4c9d66ac 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -15,6 +15,7 @@ document.addEventListener('DOMContentLoaded', () => { const notesDataset = document.getElementById('js-vue-notes').dataset; const parsedUserData = JSON.parse(notesDataset.currentUserData); const noteableData = JSON.parse(notesDataset.noteableData); + const { markdownVersion } = notesDataset; let currentUserData = {}; noteableData.noteableType = notesDataset.noteableType; @@ -33,6 +34,7 @@ document.addEventListener('DOMContentLoaded', () => { return { noteableData, currentUserData, + markdownVersion, notesData: JSON.parse(notesDataset.notesData), }; }, @@ -42,6 +44,7 @@ document.addEventListener('DOMContentLoaded', () => { noteableData: this.noteableData, notesData: this.notesData, userData: this.currentUserData, + markdownVersion: this.markdownVersion, }, }); }, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 671fa4d7d22..b2bf86eea56 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -41,6 +41,15 @@ export const fetchDiscussions = ({ commit }, path) => commit(types.SET_INITIAL_DISCUSSIONS, discussions); }); +export const refetchDiscussionById = ({ commit }, { path, discussionId }) => + service + .fetchDiscussions(path) + .then(res => res.json()) + .then(discussions => { + const selectedDiscussion = discussions.find(discussion => discussion.id === discussionId); + if (selectedDiscussion) commit(types.UPDATE_DISCUSSION, selectedDiscussion); + }); + export const deleteNote = ({ commit }, note) => service.deleteNote(note.path).then(() => { commit(types.DELETE_NOTE, note); diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index e5e40ce07fa..a1849269010 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -114,7 +114,6 @@ export default { Object.assign(state, { discussions }); }, - [types.SET_LAST_FETCHED_AT](state, fetchedAt) { Object.assign(state, { lastFetchedAt: fetchedAt }); }, diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js new file mode 100644 index 00000000000..1cd3ee1dfdb --- /dev/null +++ b/app/assets/javascripts/pages/profiles/keys/index.js @@ -0,0 +1,16 @@ +import AddSshKeyValidation from '~/profile/add_ssh_key_validation'; + +document.addEventListener('DOMContentLoaded', () => { + const input = document.querySelector('.js-add-ssh-key-validation-input'); + const warning = document.querySelector('.js-add-ssh-key-validation-warning'); + const originalSubmit = input.form.querySelector('.js-add-ssh-key-validation-original-submit'); + const confirmSubmit = warning.querySelector('.js-add-ssh-key-validation-confirm-submit'); + + const addSshKeyValidation = new AddSshKeyValidation( + input, + warning, + originalSubmit, + confirmSubmit, + ); + addSshKeyValidation.register(); +}); diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js index 0e973cab4d2..0964baf8954 100644 --- a/app/assets/javascripts/preview_markdown.js +++ b/app/assets/javascripts/preview_markdown.js @@ -28,12 +28,16 @@ MarkdownPreview.prototype.ajaxCache = {}; MarkdownPreview.prototype.showPreview = function ($form) { var mdText; + var markdownVersion; + var url; var preview = $form.find('.js-md-preview'); - var url = preview.data('url'); if (preview.hasClass('md-preview-loading')) { return; } + mdText = $form.find('textarea.markdown-area').val(); + markdownVersion = $form.attr('data-markdown-version'); + url = this.versionedPreviewPath(preview.data('url'), markdownVersion); if (mdText.trim().length === 0) { preview.text(this.emptyMessage); @@ -59,6 +63,14 @@ MarkdownPreview.prototype.showPreview = function ($form) { } }; +MarkdownPreview.prototype.versionedPreviewPath = function (markdownPreviewPath, markdownVersion) { + if (typeof markdownVersion === 'undefined') { + return markdownPreviewPath; + } + + return `${markdownPreviewPath}${markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'}markdown_version=${markdownVersion}`; +}; + MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) { if (!url) { return; diff --git a/app/assets/javascripts/profile/add_ssh_key_validation.js b/app/assets/javascripts/profile/add_ssh_key_validation.js new file mode 100644 index 00000000000..ab6a6c1896c --- /dev/null +++ b/app/assets/javascripts/profile/add_ssh_key_validation.js @@ -0,0 +1,43 @@ +export default class AddSshKeyValidation { + constructor(inputElement, warningElement, originalSubmitElement, confirmSubmitElement) { + this.inputElement = inputElement; + this.form = inputElement.form; + + this.warningElement = warningElement; + + this.originalSubmitElement = originalSubmitElement; + this.confirmSubmitElement = confirmSubmitElement; + + this.isValid = false; + } + + register() { + this.form.addEventListener('submit', event => this.submit(event)); + + this.confirmSubmitElement.addEventListener('click', () => { + this.isValid = true; + this.form.submit(); + }); + + this.inputElement.addEventListener('input', () => this.toggleWarning(false)); + } + + submit(event) { + this.isValid = AddSshKeyValidation.isPublicKey(this.inputElement.value); + + if (this.isValid) return true; + + event.preventDefault(); + this.toggleWarning(true); + return false; + } + + toggleWarning(isVisible) { + this.warningElement.classList.toggle('hide', !isVisible); + this.originalSubmitElement.classList.toggle('hide', isVisible); + } + + static isPublicKey(value) { + return /^(ssh|ecdsa-sha2)-/.test(value); + } +} diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue deleted file mode 100644 index 73d49488299..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/app.vue +++ /dev/null @@ -1,158 +0,0 @@ -<script> -import bs from '../../breakpoints'; -import eventHub from '../event_hub'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - -import projectsListFrequent from './projects_list_frequent.vue'; -import projectsListSearch from './projects_list_search.vue'; - -import search from './search.vue'; - -export default { - components: { - search, - loadingIcon, - projectsListFrequent, - projectsListSearch, - }, - props: { - currentProject: { - type: Object, - required: true, - }, - store: { - type: Object, - required: true, - }, - service: { - type: Object, - required: true, - }, - }, - data() { - return { - isLoadingProjects: false, - isFrequentsListVisible: false, - isSearchListVisible: false, - isLocalStorageFailed: false, - isSearchFailed: false, - searchQuery: '', - }; - }, - computed: { - frequentProjects() { - return this.store.getFrequentProjects(); - }, - searchProjects() { - return this.store.getSearchedProjects(); - }, - }, - created() { - if (this.currentProject.id) { - this.logCurrentProjectAccess(); - } - - eventHub.$on('dropdownOpen', this.fetchFrequentProjects); - eventHub.$on('searchProjects', this.fetchSearchedProjects); - eventHub.$on('searchCleared', this.handleSearchClear); - eventHub.$on('searchFailed', this.handleSearchFailure); - }, - beforeDestroy() { - eventHub.$off('dropdownOpen', this.fetchFrequentProjects); - eventHub.$off('searchProjects', this.fetchSearchedProjects); - eventHub.$off('searchCleared', this.handleSearchClear); - eventHub.$off('searchFailed', this.handleSearchFailure); - }, - methods: { - toggleFrequentProjectsList(state) { - this.isLoadingProjects = !state; - this.isSearchListVisible = !state; - this.isFrequentsListVisible = state; - }, - toggleSearchProjectsList(state) { - this.isLoadingProjects = !state; - this.isFrequentsListVisible = !state; - this.isSearchListVisible = state; - }, - toggleLoader(state) { - this.isFrequentsListVisible = !state; - this.isSearchListVisible = !state; - this.isLoadingProjects = state; - }, - fetchFrequentProjects() { - const screenSize = bs.getBreakpointSize(); - if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) { - this.toggleSearchProjectsList(true); - } else { - this.toggleLoader(true); - this.isLocalStorageFailed = false; - const projects = this.service.getFrequentProjects(); - if (projects) { - this.toggleFrequentProjectsList(true); - this.store.setFrequentProjects(projects); - } else { - this.isLocalStorageFailed = true; - this.toggleFrequentProjectsList(true); - this.store.setFrequentProjects([]); - } - } - }, - fetchSearchedProjects(searchQuery) { - this.searchQuery = searchQuery; - this.toggleLoader(true); - this.service - .getSearchedProjects(this.searchQuery) - .then(res => res.json()) - .then(results => { - this.toggleSearchProjectsList(true); - this.store.setSearchedProjects(results); - }) - .catch(() => { - this.isSearchFailed = true; - this.toggleSearchProjectsList(true); - }); - }, - logCurrentProjectAccess() { - this.service.logProjectAccess(this.currentProject); - }, - handleSearchClear() { - this.searchQuery = ''; - this.toggleFrequentProjectsList(true); - this.store.clearSearchedProjects(); - }, - handleSearchFailure() { - this.isSearchFailed = true; - this.toggleSearchProjectsList(true); - }, - }, -}; -</script> - -<template> - <div> - <search/> - <loading-icon - v-if="isLoadingProjects" - :label="s__('ProjectsDropdown|Loading projects')" - class="loading-animation prepend-top-20" - size="2" - /> - <div - v-if="isFrequentsListVisible" - class="section-header" - > - {{ s__('ProjectsDropdown|Frequently visited') }} - </div> - <projects-list-frequent - v-if="isFrequentsListVisible" - :local-storage-failed="isLocalStorageFailed" - :projects="frequentProjects" - /> - <projects-list-search - v-if="isSearchListVisible" - :search-failed="isSearchFailed" - :matcher="searchQuery" - :projects="searchProjects" - /> - </div> -</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue deleted file mode 100644 index 625e0aa548c..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue +++ /dev/null @@ -1,57 +0,0 @@ -<script> - import { s__ } from '../../locale'; - import projectsListItem from './projects_list_item.vue'; - - export default { - components: { - projectsListItem, - }, - props: { - projects: { - type: Array, - required: true, - }, - localStorageFailed: { - type: Boolean, - required: true, - }, - }, - computed: { - isListEmpty() { - return this.projects.length === 0; - }, - listEmptyMessage() { - return this.localStorageFailed ? - s__('ProjectsDropdown|This feature requires browser localStorage support') : - s__('ProjectsDropdown|Projects you visit often will appear here'); - }, - }, - }; -</script> - -<template> - <div - class="projects-list-frequent-container" - > - <ul - class="list-unstyled" - > - <li - v-if="isListEmpty" - class="section-empty" - > - {{ listEmptyMessage }} - </li> - <projects-list-item - v-for="(project, index) in projects" - v-else - :key="index" - :project-id="project.id" - :project-name="project.name" - :namespace="project.namespace" - :web-url="project.webUrl" - :avatar-url="project.avatarUrl" - /> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue deleted file mode 100644 index eafbf6c99e2..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue +++ /dev/null @@ -1,116 +0,0 @@ -<script> - /* eslint-disable vue/require-default-prop, vue/require-prop-types */ - import identicon from '../../vue_shared/components/identicon.vue'; - - export default { - components: { - identicon, - }, - props: { - matcher: { - type: String, - required: false, - }, - projectId: { - type: Number, - required: true, - }, - projectName: { - type: String, - required: true, - }, - namespace: { - type: String, - required: true, - }, - webUrl: { - type: String, - required: true, - }, - avatarUrl: { - required: true, - validator(value) { - return value === null || typeof value === 'string'; - }, - }, - }, - computed: { - hasAvatar() { - return this.avatarUrl !== null; - }, - highlightedProjectName() { - if (this.matcher) { - const matcherRegEx = new RegExp(this.matcher, 'gi'); - const matches = this.projectName.match(matcherRegEx); - - if (matches && matches.length > 0) { - return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`); - } - } - return this.projectName; - }, - /** - * Smartly truncates project namespace by doing two things; - * 1. Only include Group names in path by removing project name - * 2. Only include first and last group names in the path - * when namespace has more than 2 groups present - * - * First part (removal of project name from namespace) can be - * done from backend but doing so involves migration of - * existing project namespaces which is not wise thing to do. - */ - truncatedNamespace() { - const namespaceArr = this.namespace.split(' / '); - namespaceArr.splice(-1, 1); - let namespace = namespaceArr.join(' / '); - - if (namespaceArr.length > 2) { - namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`; - } - - return namespace; - }, - }, - }; -</script> - -<template> - <li - class="projects-list-item-container" - > - <a - :href="webUrl" - class="clearfix" - > - <div - class="project-item-avatar-container" - > - <img - v-if="hasAvatar" - :src="avatarUrl" - class="avatar s32" - /> - <identicon - v-else - :entity-id="projectId" - :entity-name="projectName" - size-class="s32" - /> - </div> - <div - class="project-item-metadata-container" - > - <div - :title="projectName" - class="project-title" - v-html="highlightedProjectName" - > - </div> - <div - :title="namespace" - class="project-namespace" - >{{ truncatedNamespace }}</div> - </div> - </a> - </li> -</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue deleted file mode 100644 index 76e9cb9e53f..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { s__ } from '../../locale'; -import projectsListItem from './projects_list_item.vue'; - -export default { - components: { - projectsListItem, - }, - props: { - matcher: { - type: String, - required: true, - }, - projects: { - type: Array, - required: true, - }, - searchFailed: { - type: Boolean, - required: true, - }, - }, - computed: { - isListEmpty() { - return this.projects.length === 0; - }, - listEmptyMessage() { - return this.searchFailed ? - s__('ProjectsDropdown|Something went wrong on our end.') : - s__('ProjectsDropdown|Sorry, no projects matched your search'); - }, - }, -}; -</script> - -<template> - <div - class="projects-list-search-container" - > - <ul - class="list-unstyled" - > - <li - v-if="isListEmpty" - :class="{ 'section-failure': searchFailed }" - class="section-empty" - > - {{ listEmptyMessage }} - </li> - <projects-list-item - v-for="(project, index) in projects" - v-else - :key="index" - :project-id="project.id" - :project-name="project.name" - :namespace="project.namespace" - :web-url="project.webUrl" - :avatar-url="project.avatarUrl" - :matcher="matcher" - /> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/projects_dropdown/components/search.vue b/app/assets/javascripts/projects_dropdown/components/search.vue deleted file mode 100644 index 28f2a18f2a6..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/search.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> - import _ from 'underscore'; - import eventHub from '../event_hub'; - - export default { - data() { - return { - searchQuery: '', - }; - }, - watch: { - searchQuery() { - this.handleInput(); - }, - }, - mounted() { - eventHub.$on('dropdownOpen', this.setFocus); - }, - beforeDestroy() { - eventHub.$off('dropdownOpen', this.setFocus); - }, - methods: { - setFocus() { - this.$refs.search.focus(); - }, - emitSearchEvents() { - if (this.searchQuery) { - eventHub.$emit('searchProjects', this.searchQuery); - } else { - eventHub.$emit('searchCleared'); - } - }, - /** - * Callback function within _.debounce is intentionally - * kept as ES5 `function() {}` instead of ES6 `() => {}` - * as it otherwise messes up function context - * and component reference is no longer accessible via `this` - */ - // eslint-disable-next-line func-names - handleInput: _.debounce(function () { - this.emitSearchEvents(); - }, 500), - }, - }; -</script> - -<template> - <div - class="search-input-container d-none d-sm-block" - > - <input - ref="search" - v-model="searchQuery" - :placeholder="s__('ProjectsDropdown|Search your projects')" - type="search" - class="form-control" - /> - <i - v-if="!searchQuery" - class="search-icon fa fa-fw fa-search" - aria-hidden="true" - > - </i> - </div> -</template> diff --git a/app/assets/javascripts/projects_dropdown/constants.js b/app/assets/javascripts/projects_dropdown/constants.js deleted file mode 100644 index 8937097184c..00000000000 --- a/app/assets/javascripts/projects_dropdown/constants.js +++ /dev/null @@ -1,10 +0,0 @@ -export const FREQUENT_PROJECTS = { - MAX_COUNT: 20, - LIST_COUNT_DESKTOP: 5, - LIST_COUNT_MOBILE: 3, - ELIGIBLE_FREQUENCY: 3, -}; - -export const HOUR_IN_MS = 3600000; - -export const STORAGE_KEY = 'frequent-projects'; diff --git a/app/assets/javascripts/projects_dropdown/index.js b/app/assets/javascripts/projects_dropdown/index.js deleted file mode 100644 index 6056f12aa4f..00000000000 --- a/app/assets/javascripts/projects_dropdown/index.js +++ /dev/null @@ -1,66 +0,0 @@ -import $ from 'jquery'; -import Vue from 'vue'; - -import Translate from '../vue_shared/translate'; -import eventHub from './event_hub'; -import ProjectsService from './service/projects_service'; -import ProjectsStore from './store/projects_store'; - -import projectsDropdownApp from './components/app.vue'; - -Vue.use(Translate); - -document.addEventListener('DOMContentLoaded', () => { - const el = document.getElementById('js-projects-dropdown'); - const navEl = document.getElementById('nav-projects-dropdown'); - - // Don't do anything if element doesn't exist (No projects dropdown) - // This is for when the user accesses GitLab without logging in - if (!el || !navEl) { - return; - } - - $(navEl).on('shown.bs.dropdown', () => { - eventHub.$emit('dropdownOpen'); - }); - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - projectsDropdownApp, - }, - data() { - const { dataset } = this.$options.el; - const store = new ProjectsStore(); - const service = new ProjectsService(dataset.userName); - - const project = { - id: Number(dataset.projectId), - name: dataset.projectName, - namespace: dataset.projectNamespace, - webUrl: dataset.projectWebUrl, - avatarUrl: dataset.projectAvatarUrl || null, - lastAccessedOn: Date.now(), - }; - - return { - store, - service, - state: store.state, - currentUserName: dataset.userName, - currentProject: project, - }; - }, - render(createElement) { - return createElement('projects-dropdown-app', { - props: { - currentUserName: this.currentUserName, - currentProject: this.currentProject, - store: this.store, - service: this.service, - }, - }); - }, - }); -}); diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js deleted file mode 100644 index ed1c3deead2..00000000000 --- a/app/assets/javascripts/projects_dropdown/service/projects_service.js +++ /dev/null @@ -1,137 +0,0 @@ -import _ from 'underscore'; -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -import bp from '../../breakpoints'; -import Api from '../../api'; -import AccessorUtilities from '../../lib/utils/accessor'; - -import { FREQUENT_PROJECTS, HOUR_IN_MS, STORAGE_KEY } from '../constants'; - -Vue.use(VueResource); - -export default class ProjectsService { - constructor(currentUserName) { - this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); - this.currentUserName = currentUserName; - this.storageKey = `${this.currentUserName}/${STORAGE_KEY}`; - this.projectsPath = Vue.resource(Api.buildUrl(Api.projectsPath)); - } - - getSearchedProjects(searchQuery) { - return this.projectsPath.get({ - simple: true, - per_page: 20, - membership: !!gon.current_user_id, - order_by: 'last_activity_at', - search: searchQuery, - }); - } - - getFrequentProjects() { - if (this.isLocalStorageAvailable) { - return this.getTopFrequentProjects(); - } - return null; - } - - logProjectAccess(project) { - let matchFound = false; - let storedFrequentProjects; - - if (this.isLocalStorageAvailable) { - const storedRawProjects = localStorage.getItem(this.storageKey); - - // Check if there's any frequent projects list set - if (!storedRawProjects) { - // No frequent projects list set, set one up. - storedFrequentProjects = []; - storedFrequentProjects.push({ ...project, frequency: 1 }); - } else { - // Check if project is already present in frequents list - // When found, update metadata of it. - storedFrequentProjects = JSON.parse(storedRawProjects).map(projectItem => { - if (projectItem.id === project.id) { - matchFound = true; - const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS; - const updatedProject = { - ...project, - frequency: projectItem.frequency, - lastAccessedOn: projectItem.lastAccessedOn, - }; - - // Check if duration since last access of this project - // is over an hour - if (diff > 1) { - return { - ...updatedProject, - frequency: updatedProject.frequency + 1, - lastAccessedOn: Date.now(), - }; - } - - return { - ...updatedProject, - }; - } - - return projectItem; - }); - - // Check whether currently logged project is present in frequents list - if (!matchFound) { - // We always keep size of frequents collection to 20 projects - // out of which only 5 projects with - // highest value of `frequency` and most recent `lastAccessedOn` - // are shown in projects dropdown - if (storedFrequentProjects.length === FREQUENT_PROJECTS.MAX_COUNT) { - storedFrequentProjects.shift(); // Remove an item from head of array - } - - storedFrequentProjects.push({ ...project, frequency: 1 }); - } - } - - localStorage.setItem(this.storageKey, JSON.stringify(storedFrequentProjects)); - } - } - - getTopFrequentProjects() { - const storedFrequentProjects = JSON.parse(localStorage.getItem(this.storageKey)); - let frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_DESKTOP; - - if (!storedFrequentProjects) { - return []; - } - - if (bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'xs') { - frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE; - } - - const frequentProjects = storedFrequentProjects.filter( - project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY, - ); - - if (!frequentProjects || frequentProjects.length === 0) { - return []; - } - - // Sort all frequent projects in decending order of frequency - // and then by lastAccessedOn with recent most first - frequentProjects.sort((projectA, projectB) => { - if (projectA.frequency < projectB.frequency) { - return 1; - } else if (projectA.frequency > projectB.frequency) { - return -1; - } else if (projectA.lastAccessedOn < projectB.lastAccessedOn) { - return 1; - } else if (projectA.lastAccessedOn > projectB.lastAccessedOn) { - return -1; - } - - return 0; - }); - - return _.first(frequentProjects, frequentProjectsCount); - } -} diff --git a/app/assets/javascripts/projects_dropdown/store/projects_store.js b/app/assets/javascripts/projects_dropdown/store/projects_store.js deleted file mode 100644 index ffefbe693f4..00000000000 --- a/app/assets/javascripts/projects_dropdown/store/projects_store.js +++ /dev/null @@ -1,33 +0,0 @@ -export default class ProjectsStore { - constructor() { - this.state = {}; - this.state.frequentProjects = []; - this.state.searchedProjects = []; - } - - setFrequentProjects(rawProjects) { - this.state.frequentProjects = rawProjects; - } - - getFrequentProjects() { - return this.state.frequentProjects; - } - - setSearchedProjects(rawProjects) { - this.state.searchedProjects = rawProjects.map(rawProject => ({ - id: rawProject.id, - name: rawProject.name, - namespace: rawProject.name_with_namespace, - webUrl: rawProject.web_url, - avatarUrl: rawProject.avatar_url, - })); - } - - getSearchedProjects() { - return this.state.searchedProjects; - } - - clearSearchedProjects() { - this.state.searchedProjects = []; - } -} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index 5e464f8a0e2..21f21232596 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -79,66 +79,62 @@ export default { </script> <template> - <div class="mr-widget-heading deploy-heading"> + <div class="mr-widget-heading deploy-heading append-bottom-default"> <div class="ci-widget media"> - <div class="ci-status-icon ci-status-icon-success"> - <span class="js-icon-link icon-link"> - <status-icon status="success" /> - </span> - </div> <div class="media-body"> <div class="deploy-body"> - <template v-if="hasDeploymentMeta"> - <span> - Deployed to - </span> - <a - :href="deployment.url" - target="_blank" - rel="noopener noreferrer nofollow" - class="deploy-link js-deploy-meta" + <div class="deployment-info"> + <template v-if="hasDeploymentMeta"> + <span> + Deployed to + </span> + <a + :href="deployment.url" + target="_blank" + rel="noopener noreferrer nofollow" + class="deploy-link js-deploy-meta" + > + {{ deployment.name }} + </a> + </template> + <span + v-tooltip + v-if="hasDeploymentTime" + :title="deployment.deployed_at_formatted" + class="js-deploy-time" > - {{ deployment.name }} - </a> - </template> - <template v-if="hasExternalUrls"> - <span> - on + {{ deployTimeago }} </span> + <memory-usage + v-if="hasMetrics" + :metrics-url="deployment.metrics_url" + :metrics-monitoring-url="deployment.metrics_monitoring_url" + /> + </div> + <div> <a + v-if="hasExternalUrls" :href="deployment.external_url" target="_blank" rel="noopener noreferrer nofollow" - class="deploy-link js-deploy-url" + class="deploy-link js-deploy-url btn btn-default btn-sm inline" > - {{ deployment.external_url_formatted }} - <icon - :size="16" - name="external-link" - /> + <span> + View app + <icon name="external-link" /> + </span> </a> - </template> - <span - v-tooltip - v-if="hasDeploymentTime" - :title="deployment.deployed_at_formatted" - class="js-deploy-time" - > - {{ deployTimeago }} - </span> - <loading-button - v-if="deployment.stop_url" - :loading="isStopping" - container-class="btn btn-default btn-sm prepend-left-default" - label="Stop environment" - @click="stopEnvironment" - /> + <loading-button + v-if="deployment.stop_url" + :loading="isStopping" + container-class="btn btn-default btn-sm inline prepend-left-4" + title="Stop environment" + @click="stopEnvironment" + > + <icon name="stop" /> + </loading-button> + </div> </div> - <memory-usage - v-if="hasMetrics" - :metrics-url="deployment.metrics_url" - :metrics-monitoring-url="deployment.metrics_monitoring_url" - /> </div> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 3ce9d8dc26a..c18b74743e4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -2,7 +2,7 @@ import tooltip from '~/vue_shared/directives/tooltip'; import { n__ } from '~/locale'; import { webIDEUrl } from '~/lib/utils/url_utility'; -import icon from '~/vue_shared/components/icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; export default { @@ -11,7 +11,7 @@ export default { tooltip, }, components: { - icon, + Icon, clipboardButton, }, props: { @@ -54,104 +54,114 @@ export default { }; </script> <template> - <div class="mr-source-target"> - <div class="normal"> - <strong> - {{ s__("mrWidget|Request to merge") }} - <span - :class="{ 'label-truncated': isSourceBranchLong }" - :title="isSourceBranchLong ? mr.sourceBranch : ''" - :v-tooltip="isSourceBranchLong" - class="label-branch js-source-branch" - data-placement="bottom" - v-html="mr.sourceBranchLink" - > - </span> + <div class="mr-source-target append-bottom-default"> + <div class="git-merge-icon-container append-right-default"> + <icon name="git-merge" /> + </div> + <div class="git-merge-container d-flex"> + <div class="normal"> + <strong> + {{ s__("mrWidget|Request to merge") }} + <span + :class="{ 'label-truncated': isSourceBranchLong }" + :title="isSourceBranchLong ? mr.sourceBranch : ''" + :v-tooltip="isSourceBranchLong" + class="label-branch js-source-branch" + data-placement="bottom" + v-html="mr.sourceBranchLink" + > + </span> - <clipboard-button - :text="branchNameClipboardData" - :title="__('Copy branch name to clipboard')" - css-class="btn-default btn-transparent btn-clipboard" - /> + <clipboard-button + :text="branchNameClipboardData" + :title="__('Copy branch name to clipboard')" + css-class="btn-default btn-transparent btn-clipboard" + /> - {{ s__("mrWidget|into") }} + {{ s__("mrWidget|into") }} - <span - :v-tooltip="isTargetBranchLong" - :class="{ 'label-truncatedtooltip': isTargetBranchLong }" - :title="isTargetBranchLong ? mr.targetBranch : ''" - class="label-branch" - data-placement="bottom" - > - <a - :href="mr.targetBranchTreePath" - class="js-target-branch" + <span + :v-tooltip="isTargetBranchLong" + :class="{ 'label-truncatedtooltip': isTargetBranchLong }" + :title="isTargetBranchLong ? mr.targetBranch : ''" + class="label-branch" + data-placement="bottom" > - {{ mr.targetBranch }} - </a> - </span> - </strong> - <span - v-if="shouldShowCommitsBehindText" - class="diverged-commits-count" - > - (<a :href="mr.targetBranchPath">{{ commitsText }}</a>) - </span> - </div> + <a + :href="mr.targetBranchTreePath" + class="js-target-branch" + > + {{ mr.targetBranch }} + </a> + </span> + </strong> + <div + v-if="shouldShowCommitsBehindText" + class="diverged-commits-count" + > + <span class="monospace">{{ mr.sourceBranch }}</span> + is {{ commitsText }} + <span class="monospace">{{ mr.targetBranch }}</span> + </div> + </div> - <div v-if="mr.isOpen"> - <a - v-if="!mr.sourceBranchRemoved" - :href="webIdePath" - class="btn btn-sm btn-default inline js-web-ide" - > - {{ s__("mrWidget|Web IDE") }} - </a> - <button - :disabled="mr.sourceBranchRemoved" - data-target="#modal_merge_info" - data-toggle="modal" - class="btn btn-sm btn-default inline js-check-out-branch" - type="button" + <div + v-if="mr.isOpen" + class="branch-actions" > - {{ s__("mrWidget|Check out branch") }} - </button> - <span class="dropdown prepend-left-10"> + <a + v-if="!mr.sourceBranchRemoved" + :href="webIdePath" + class="btn btn-default inline js-web-ide d-none d-md-inline-block" + > + {{ s__("mrWidget|Open in Web IDE") }} + </a> <button + :disabled="mr.sourceBranchRemoved" + data-target="#modal_merge_info" + data-toggle="modal" + class="btn btn-default inline js-check-out-branch" type="button" - class="btn btn-sm inline dropdown-toggle" - data-toggle="dropdown" - aria-label="Download as" - aria-haspopup="true" - aria-expanded="false" > - <icon name="download" /> - <i - class="fa fa-caret-down" - aria-hidden="true"> - </i> + {{ s__("mrWidget|Check out branch") }} </button> - <ul class="dropdown-menu dropdown-menu-right"> - <li> - <a - :href="mr.emailPatchesPath" - class="js-download-email-patches" - download - > - {{ s__("mrWidget|Email patches") }} - </a> - </li> - <li> - <a - :href="mr.plainDiffPath" - class="js-download-plain-diff" - download - > - {{ s__("mrWidget|Plain diff") }} - </a> - </li> - </ul> - </span> + <span class="dropdown prepend-left-10"> + <button + type="button" + class="btn inline dropdown-toggle" + data-toggle="dropdown" + aria-label="Download as" + aria-haspopup="true" + aria-expanded="false" + > + <icon name="download" /> + <i + class="fa fa-caret-down" + aria-hidden="true"> + </i> + </button> + <ul class="dropdown-menu dropdown-menu-right"> + <li> + <a + :href="mr.emailPatchesPath" + class="js-download-email-patches" + download + > + {{ s__("mrWidget|Email patches") }} + </a> + </li> + <li> + <a + :href="mr.plainDiffPath" + class="js-download-plain-diff" + download + > + {{ s__("mrWidget|Plain diff") }} + </a> + </li> + </ul> + </span> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 2f0b5e12c12..4a3fd01fa39 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -26,6 +26,10 @@ export default { type: String, required: false, }, + sourceBranchLink: { + type: String, + required: false, + }, }, computed: { hasPipeline() { @@ -54,12 +58,18 @@ export default { <template> <div v-if="hasPipeline || hasCIError" - class="mr-widget-heading" + class="mr-widget-heading append-bottom-default" > <div class="ci-widget media"> <template v-if="hasCIError"> - <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-10"> - <icon name="status_failed" /> + <div + class="add-border ci-status-icon ci-status-icon-failed ci-error + js-ci-error append-right-default" + > + <icon + :size="32" + name="status_failed_borderless" + /> </div> <div class="media-body"> Could not connect to the CI server. Please check your settings and try again @@ -68,50 +78,66 @@ export default { <template v-else-if="hasPipeline"> <a :href="status.details_path" - class="append-right-10" + class="align-self-start append-right-default" > - <ci-icon :status="status" /> + <ci-icon + :status="status" + :size="32" + :borderless="true" + class="add-border" + /> </a> + <div class="ci-widget-container d-flex"> + <div class="ci-widget-content"> + <div class="media-body"> + <div class="font-weight-bold"> + Pipeline + <a + :href="pipeline.path" + class="pipeline-id font-weight-normal pipeline-number" + >#{{ pipeline.id }}</a> - <div class="media-body"> - Pipeline - <a - :href="pipeline.path" - class="pipeline-id" - > - #{{ pipeline.id }} - </a> - - {{ pipeline.details.status.label }} + {{ pipeline.details.status.label }} - <template v-if="hasCommitInfo"> - for - - <a - :href="pipeline.commit.commit_path" - class="commit-sha js-commit-link" - > - {{ pipeline.commit.short_id }}</a>. - </template> - - <span class="mr-widget-pipeline-graph"> - <span - v-if="hasStages" - class="stage-cell" - > + <template v-if="hasCommitInfo"> + for + <a + :href="pipeline.commit.commit_path" + class="commit-sha js-commit-link font-weight-normal" + > + {{ pipeline.commit.short_id }}</a> + on + <span + class="label-branch" + v-html="sourceBranchLink" + > + </span> + </template> + </div> <div - v-for="(stage, i) in pipeline.details.stages" - :key="i" - class="stage-container dropdown js-mini-pipeline-graph" + v-if="pipeline.coverage" + class="coverage" > - <pipeline-stage :stage="stage" /> + Coverage {{ pipeline.coverage }}% </div> + </div> + </div> + <div> + <span class="mr-widget-pipeline-graph"> + <span + v-if="hasStages" + class="stage-cell" + > + <div + v-for="(stage, i) in pipeline.details.stages" + :key="i" + class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" + > + <pipeline-stage :stage="stage" /> + </div> + </span> </span> - </span> - - <template v-if="pipeline.coverage"> - Coverage {{ pipeline.coverage }}% - </template> + </div> </div> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 53c4dc8c8f4..55b87f3a8ec 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -43,6 +43,7 @@ <ci-icon v-else :status="statusObj" + :size="24" /> <button diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 09477da40b5..b5de3dd6d73 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -252,41 +252,44 @@ export default { :pipeline="mr.pipeline" :ci-status="mr.ciStatus" :has-ci="mr.hasCI" + :source-branch-link="mr.sourceBranchLink" /> <deployment v-for="deployment in mr.deployments" :key="deployment.id" :deployment="deployment" /> - <div class="mr-widget-section"> - <component - :is="componentName" - :mr="mr" - :service="service" - /> + <div class="mr-section-container"> + <div class="mr-widget-section"> + <component + :is="componentName" + :mr="mr" + :service="service" + /> - <section - v-if="mr.allowCollaboration" - class="mr-info-list mr-links" - > - {{ s__("mrWidget|Allows commits from members who can merge to the target branch") }} - </section> + <section + v-if="mr.allowCollaboration" + class="mr-info-list mr-links" + > + {{ s__("mrWidget|Allows commits from members who can merge to the target branch") }} + </section> - <mr-widget-related-links - v-if="shouldRenderRelatedLinks" - :state="mr.state" - :related-links="mr.relatedLinks" - /> + <mr-widget-related-links + v-if="shouldRenderRelatedLinks" + :state="mr.state" + :related-links="mr.relatedLinks" + /> - <source-branch-removal-status - v-if="shouldRenderSourceBranchRemovalStatus" - /> - </div> - <div - v-if="shouldRenderMergeHelp" - class="mr-widget-footer" - > - <mr-widget-merge-help /> + <source-branch-removal-status + v-if="shouldRenderSourceBranchRemovalStatus" + /> + </div> + <div + v-if="shouldRenderMergeHelp" + class="mr-widget-footer" + > + <mr-widget-merge-help /> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 298971a36b2..d62537021ca 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -1,5 +1,6 @@ <script> import $ from 'jquery'; + import { s__ } from '~/locale'; import Flash from '../../../flash'; import GLForm from '../../../gl_form'; import markdownHeader from './header.vue'; @@ -22,6 +23,11 @@ type: String, required: true, }, + markdownVersion: { + type: Number, + required: false, + default: 0, + }, addSpacingClasses: { type: Boolean, required: false, @@ -92,10 +98,11 @@ if (text) { this.markdownPreviewLoading = true; - this.$http.post(this.markdownPreviewPath, { text }) - .then(resp => resp.json()) - .then(data => this.renderMarkdown(data)) - .catch(() => new Flash('Error loading markdown preview')); + this.$http + .post(this.versionedPreviewPath(), { text }) + .then(resp => resp.json()) + .then(data => this.renderMarkdown(data)) + .catch(() => new Flash(s__('Error loading markdown preview'))); } else { this.renderMarkdown(); } @@ -119,6 +126,13 @@ $(this.$refs['markdown-preview']).renderGFM(); }); }, + + versionedPreviewPath() { + const { markdownPreviewPath, markdownVersion } = this; + return `${markdownPreviewPath}${ + markdownPreviewPath.indexOf('?') === -1 ? '?' : '&' + }markdown_version=${markdownVersion}`; + }, }, }; </script> diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 74475daae14..c7b5e22c33d 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -36,7 +36,7 @@ width: 100%; } - &.projects-dropdown-menu { + &.frequent-items-dropdown-menu { padding: 0; overflow-y: initial; max-height: initial; @@ -790,6 +790,7 @@ @include media-breakpoint-down(xs) { .navbar-gitlab { li.header-projects, + li.header-groups, li.header-more, li.header-new, li.header-user { @@ -813,18 +814,18 @@ } } -header.header-content .dropdown-menu.projects-dropdown-menu { +header.header-content .dropdown-menu.frequent-items-dropdown-menu { padding: 0; } -.projects-dropdown-container { +.frequent-items-dropdown-container { display: flex; flex-direction: row; width: 500px; height: 334px; - .project-dropdown-sidebar, - .project-dropdown-content { + .frequent-items-dropdown-sidebar, + .frequent-items-dropdown-content { padding: 8px 0; } @@ -832,12 +833,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu { color: $almost-black; } - .project-dropdown-sidebar { + .frequent-items-dropdown-sidebar { width: 30%; border-right: 1px solid $border-color; } - .project-dropdown-content { + .frequent-items-dropdown-content { position: relative; width: 70%; } @@ -848,33 +849,35 @@ header.header-content .dropdown-menu.projects-dropdown-menu { height: auto; flex: 1; - .project-dropdown-sidebar, - .project-dropdown-content { + .frequent-items-dropdown-sidebar, + .frequent-items-dropdown-content { width: 100%; } - .project-dropdown-sidebar { + .frequent-items-dropdown-sidebar { border-bottom: 1px solid $border-color; border-right: 0; } } - .projects-list-frequent-container, - .projects-list-search-container { + .section-header, + .frequent-items-list-container li.section-empty { + padding: 0 $gl-padding; + color: $gl-text-color-secondary; + font-size: $gl-font-size; + } + + .frequent-items-list-container { padding: 8px 0; overflow-y: auto; li.section-empty.section-failure { color: $callout-danger-color; } - } - .section-header, - .projects-list-frequent-container li.section-empty, - .projects-list-search-container li.section-empty { - padding: 0 15px; - color: $gl-text-color-secondary; - font-size: $gl-font-size; + .frequent-items-list-item-container a { + display: flex; + } } .search-input-container { @@ -894,12 +897,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu { margin-top: 8px; } - .projects-list-search-container { + .frequent-items-search-container { height: 284px; } @include media-breakpoint-down(xs) { - .projects-list-frequent-container { + .frequent-items-list-container { width: auto; height: auto; padding-bottom: 0; @@ -907,32 +910,38 @@ header.header-content .dropdown-menu.projects-dropdown-menu { } } -.projects-list-item-container { - .project-item-avatar-container .project-item-metadata-container { +.frequent-items-list-item-container { + .frequent-items-item-avatar-container, + .frequent-items-item-metadata-container { float: left; } - .project-title, - .project-namespace { + .frequent-items-item-metadata-container { + display: flex; + flex-direction: column; + justify-content: center; + } + + .frequent-items-item-title, + .frequent-items-item-namespace { max-width: 250px; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } &:hover { - .project-item-avatar-container .avatar { + .frequent-items-item-avatar-container .avatar { border-color: $md-area-border; } } - .project-title { + .frequent-items-item-title { font-size: $gl-font-size; font-weight: 400; line-height: 16px; } - .project-namespace { + .frequent-items-item-namespace { margin-top: 4px; font-size: 12px; line-height: 12px; @@ -940,7 +949,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu { } @include media-breakpoint-down(xs) { - .project-item-metadata-container { + .frequent-items-item-metadata-container { float: none; } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 551a7e852ae..5d79610b21e 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -224,7 +224,10 @@ .form-control { position: relative; min-width: 200px; - padding: 5px 25px 6px 0; + padding-right: 25px; + padding-left: 0; + height: $input-height; + line-height: inherit; border-color: transparent; &:focus, diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 282e424fc38..a22454c24e2 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -255,3 +255,8 @@ label { color: $theme-gray-600; } } + +.input-lg { + max-width: 320px; + width: 100%; +} diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index aaa8bed3df0..dff6bce370f 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -29,15 +29,21 @@ .navbar-sub-nav, .navbar-nav { > li { - > a:hover, - > a:focus { - background-color: rgba($search-and-nav-links, 0.2); + > a, + > button { + &:hover, + &:focus { + background-color: rgba($search-and-nav-links, 0.2); + } } - &.active > a, - &.dropdown.show > a { - color: $nav-svg-color; - background-color: $color-alternate; + &.active, + &.dropdown.show { + > a, + > button { + color: $nav-svg-color; + background-color: $color-alternate; + } } &.line-separator { @@ -147,7 +153,6 @@ } } - // Sidebar .nav-sidebar li.active { box-shadow: inset 4px 0 0 $border-and-box-shadow; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 8bcaf5eb6ac..2097bcebf69 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -269,14 +269,8 @@ .navbar-sub-nav, .navbar-nav { > li { - > a:hover, - > a:focus { - text-decoration: none; - outline: 0; - color: $white-light; - } - - > a { + > a, + > button { display: -webkit-flex; display: flex; align-items: center; @@ -288,6 +282,18 @@ border-radius: $border-radius-default; height: 32px; font-weight: $gl-font-weight-bold; + + &:hover, + &:focus { + text-decoration: none; + outline: 0; + color: $white-light; + } + } + + > button { + background: transparent; + border: 0; } &.line-separator { @@ -311,7 +317,7 @@ font-size: 10px; } - .project-item-select-holder { + .frequent-items-item-select-holder { display: inline; } diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 30314f3d6cb..d1f7ff4438b 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -3,12 +3,20 @@ svg { fill: $green-500; } + + &.add-border { + @include borderless-status-icon($green-500); + } } .ci-status-icon-failed { svg { fill: $gl-danger; } + + &.add-border { + @include borderless-status-icon($red-500); + } } .ci-status-icon-pending, @@ -17,12 +25,20 @@ svg { fill: $orange-500; } + + &.add-border { + @include borderless-status-icon($orange-500); + } } .ci-status-icon-running { svg { fill: $blue-400; } + + &.add-border { + @include borderless-status-icon($blue-400); + } } .ci-status-icon-canceled, @@ -30,6 +46,10 @@ svg { fill: $gl-text-color; } + + &.add-border { + @include borderless-status-icon($gl-text-color); + } } .ci-status-icon-created, @@ -38,6 +58,10 @@ svg { fill: $gray-darkest; } + + &.add-border { + @include borderless-status-icon($gray-darkest); + } } .ci-status-icon-manual { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 0b645eb811b..76ebfc22ef7 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -232,3 +232,10 @@ word-break: break-word; max-width: 100%; } + +@mixin borderless-status-icon($color) { + svg { + border: 1px solid $color; + border-radius: 50%; + } +} diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 54799593727..c1836228a49 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -350,7 +350,8 @@ code { } .commit-sha, -.ref-name { +.ref-name, +.pipeline-number { @extend .monospace; font-size: 95%; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 7808f6d3a25..6cfa09b56a7 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -743,6 +743,7 @@ Pipeline Graph */ $stage-hover-bg: $gray-darker; $ci-action-icon-size: 22px; +$ci-action-icon-size-lg: 24px; $pipeline-dropdown-line-height: 20px; $pipeline-dropdown-status-icon-size: 18px; $ci-action-dropdown-button-size: 24px; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index ca63a8ae916..202377d6d9e 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -80,7 +80,6 @@ overflow-x: scroll; white-space: nowrap; min-height: 200px; - display: flex; @include media-breakpoint-only(sm) { height: calc(100vh - #{$issue-board-list-difference-sm}); @@ -111,15 +110,17 @@ .board { display: inline-block; - flex: 1; - min-width: 300px; - max-width: 400px; + width: calc(85vw - 15px); height: 100%; padding-right: ($gl-padding / 2); padding-left: ($gl-padding / 2); white-space: normal; vertical-align: top; + @include media-breakpoint-up(sm) { + width: 400px; + } + &.is-expandable { .board-header { cursor: pointer; @@ -127,8 +128,6 @@ } &.is-collapsed { - flex: none; - min-width: 0; width: 50px; .board-header { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index efd730af558..c32049e1b33 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -15,16 +15,38 @@ } } +.mr-widget-heading { + position: relative; + border: 1px solid $border-color; + border-radius: 4px; + + &:not(.deploy-heading)::before { + content: ''; + border-left: 1px solid $theme-gray-200; + position: absolute; + left: 32px; + top: -17px; + height: 16px; + } +} + +.mr-section-container { + border: 1px solid $border-color; + border-radius: $border-radius-default; + border-top: 0; +} + +.mr-widget-heading, +.mr-widget-section, +.mr-widget-footer { + padding: $gl-padding; +} + .mr-state-widget { color: $gl-text-color; - border: 1px solid $border-color; - border-radius: 2px; - line-height: 28px; - .mr-widget-heading, .mr-widget-section, .mr-widget-footer { - padding: $gl-padding; border-top: solid 1px $border-color; } @@ -124,10 +146,17 @@ .ci-widget { color: $gl-text-color; display: flex; + align-items: center; + justify-content: space-between; @include media-breakpoint-down(xs) { flex-wrap: wrap; } + + .ci-widget-content { + display: flex; + align-items: center; + } } .mr-widget-icon { @@ -136,8 +165,6 @@ } .ci-status-icon svg { - width: $status-icon-size; - height: $status-icon-size; margin: 3px 0; position: relative; overflow: visible; @@ -145,8 +172,6 @@ } .mr-widget-pipeline-graph { - padding: 0 4px; - .dropdown-menu { z-index: 300; } @@ -157,7 +182,7 @@ } .normal { - line-height: 28px; + flex: 1; } .capitalize { @@ -168,7 +193,7 @@ @extend .ref-name; color: $gl-text-color; - font-weight: $gl-font-weight-bold; + font-weight: normal; overflow: hidden; word-break: break-all; @@ -192,6 +217,8 @@ } .mr-widget-body { + line-height: 28px; + @include clearfix; &.media > *:first-child { @@ -474,18 +501,66 @@ } } +.merge-request-details .content-block { + border-bottom: 0; +} + .mr-source-target { display: flex; flex-wrap: wrap; - justify-content: space-between; - align-items: center; - background-color: $gray-light; - border-radius: $border-radius-default $border-radius-default 0 0; - padding: $gl-padding / 2 $gl-padding; + border-radius: $border-radius-default; + padding: $gl-padding; + border: 1px solid $border-color; + min-height: 69px; + + @include media-breakpoint-up(md) { + align-items: center; + } .dropdown-toggle .fa { color: $gl-text-color; } + + .git-merge-icon-container { + border: 1px solid $theme-gray-400; + border-radius: 50%; + height: 32px; + width: 32px; + color: $theme-gray-700; + line-height: 28px; + + .ic-git-merge { + vertical-align: middle; + width: 31px; + } + } + + .git-merge-container { + justify-content: space-between; + flex: 1; + flex-direction: row; + align-items: center; + + @include media-breakpoint-down(md) { + flex-direction: column; + align-items: flex-start; + + .branch-actions { + margin-top: 16px; + } + } + + @include media-breakpoint-up(lg) { + .branch-actions { + align-self: center; + } + } + } + + .diverged-commits-count { + color: $gl-text-color-secondary; + font-size: 12px; + } } .card-new-merge-request { @@ -720,13 +795,25 @@ } .deploy-heading { + margin-top: -19px; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-color: $gray-light; + + @include media-breakpoint-up(md) { + padding: $gl-padding-8 $gl-padding; + } + .media-body { min-width: 0; + font-size: 12px; + margin-left: 48px; } } .deploy-body { display: flex; + align-items: center; flex-wrap: wrap; @include media-breakpoint-up(xs) { @@ -734,6 +821,15 @@ white-space: nowrap; } + @include media-breakpoint-down(md) { + flex-direction: column; + align-items: flex-start; + + .deployment-info { + margin-bottom: $gl-padding; + } + } + > *:not(:last-child) { margin-right: .3em; } @@ -741,18 +837,22 @@ svg { vertical-align: text-top; } -} -.deploy-link { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 100px; - max-width: 150px; + .deployment-info { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 100px; - @include media-breakpoint-up(xs) { - min-width: 0; - max-width: 100%; + @include media-breakpoint-up(xs) { + min-width: 0; + max-width: 100%; + } + } + + .btn svg { + fill: $theme-gray-700; } } @@ -772,3 +872,33 @@ } } } + +.ci-widget-container { + justify-content: space-between; + flex: 1; + flex-direction: row; + + @include media-breakpoint-down(md) { + flex-direction: column; + + .stage-cell .stage-container { + margin-top: 16px; + } + + .dropdown .mini-pipeline-graph-dropdown-menu.dropdown-menu { + transform: initial; + } + } + + .coverage { + font-size: 12px; + color: $theme-gray-700; + line-height: initial; + } + + .mini-pipeline-graph-dropdown-toggle, + .stage-cell .mini-pipeline-graph-dropdown-toggle svg { + height: $ci-action-icon-size-lg; + width: $ci-action-icon-size-lg; + } +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 52332ac97dd..b68c89c25d8 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -301,6 +301,21 @@ border-bottom: 2px solid $border-color; } } + + //delete when all pipelines are updated to new size + &.mr-widget-pipeline-stages { + + .stage-container { + margin-left: 4px; + } + + &:not(:last-child) { + &::after { + width: 4px; + right: -4px; + top: 11px; + } + } + } } } diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 3c24aaa65e8..6e2b285285a 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -1329,3 +1329,14 @@ line-height: 16px; color: $gl-text-color-secondary; } + +.ide-merge-request-info { + .detail-page-header { + line-height: initial; + min-height: 38px; + } + + .issuable-details { + overflow: auto; + } +} diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb index b0c4c31cffc..5c2025c1988 100644 --- a/app/controllers/admin/deploy_keys_controller.rb +++ b/app/controllers/admin/deploy_keys_controller.rb @@ -22,7 +22,7 @@ class Admin::DeployKeysController < Admin::ApplicationController end def update - if deploy_key.update_attributes(update_params) + if deploy_key.update(update_params) flash[:notice] = 'Deploy key was successfully updated.' redirect_to admin_deploy_keys_path else @@ -34,7 +34,7 @@ class Admin::DeployKeysController < Admin::ApplicationController deploy_key.destroy respond_to do |format| - format.html { redirect_to admin_deploy_keys_path, status: 302 } + format.html { redirect_to admin_deploy_keys_path, status: :found } format.json { head :ok } end end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 96b7bc65ac9..d7a5b745d3f 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -39,7 +39,7 @@ class Admin::GroupsController < Admin::ApplicationController end def update - if @group.update_attributes(group_params) + if @group.update(group_params) redirect_to [:admin, @group], notice: 'Group was successfully updated.' else render "edit" diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb index 6944857bd33..a98c355c7ba 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -23,7 +23,7 @@ class Admin::HooksController < Admin::ApplicationController end def update - if hook.update_attributes(hook_params) + if hook.update(hook_params) flash[:notice] = 'System hook was successfully updated.' redirect_to admin_hooks_path else @@ -34,7 +34,7 @@ class Admin::HooksController < Admin::ApplicationController def destroy hook.destroy - redirect_to admin_hooks_path, status: 302 + redirect_to admin_hooks_path, status: :found end def test diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb index 43b4e3a2cc3..ceb45865804 100644 --- a/app/controllers/admin/identities_controller.rb +++ b/app/controllers/admin/identities_controller.rb @@ -25,7 +25,7 @@ class Admin::IdentitiesController < Admin::ApplicationController end def update - if @identity.update_attributes(identity_params) + if @identity.update(identity_params) RepairLdapBlockedUserService.new(@user).execute redirect_to admin_user_identities_path(@user), notice: 'User identity was successfully updated.' else diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb index 39dbf85f6c0..d2f947d2c66 100644 --- a/app/controllers/admin/impersonations_controller.rb +++ b/app/controllers/admin/impersonations_controller.rb @@ -11,7 +11,7 @@ class Admin::ImpersonationsController < Admin::ApplicationController session[:impersonator_id] = nil - redirect_to admin_user_path(original_user), status: 302 + redirect_to admin_user_path(original_user), status: :found end private diff --git a/app/controllers/admin/jobs_controller.rb b/app/controllers/admin/jobs_controller.rb index ae7a7f6279c..ac1ae0f16b3 100644 --- a/app/controllers/admin/jobs_controller.rb +++ b/app/controllers/admin/jobs_controller.rb @@ -20,6 +20,6 @@ class Admin::JobsController < Admin::ApplicationController def cancel_all Ci::Build.running_or_pending.each(&:cancel) - redirect_to admin_jobs_path, status: 303 + redirect_to admin_jobs_path, status: :see_other end end diff --git a/app/controllers/admin/runner_projects_controller.rb b/app/controllers/admin/runner_projects_controller.rb index 7aba77d8129..51d5799cd89 100644 --- a/app/controllers/admin/runner_projects_controller.rb +++ b/app/controllers/admin/runner_projects_controller.rb @@ -16,7 +16,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController runner = rp.runner rp.destroy - redirect_to admin_runner_path(runner), status: 302 + redirect_to admin_runner_path(runner), status: :found end private diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 4b01904f2a1..6c76c55a9d4 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -28,7 +28,7 @@ class Admin::RunnersController < Admin::ApplicationController def destroy @runner.destroy - redirect_to admin_runners_path, status: 302 + redirect_to admin_runners_path, status: :found end def resume diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index a7025b62ad7..e70aa549140 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -16,7 +16,7 @@ class Admin::ServicesController < Admin::ApplicationController end def update - if service.update_attributes(service_params[:service]) + if service.update(service_params[:service]) PropagateServiceTemplateWorker.perform_async(service.id) if service.active? redirect_to admin_application_settings_services_path, diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 653f3dfffc4..a51a8c3ed4a 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -163,7 +163,7 @@ class Admin::UsersController < Admin::ApplicationController format.json { head :ok } else format.html { redirect_back_or_admin_user(alert: 'There was an error removing the e-mail.') } - format.json { render json: 'There was an error removing the e-mail.', status: 400 } + format.json { render json: 'There was an error removing the e-mail.', status: :bad_request } end end end diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb index 56770a17406..6ec6897e707 100644 --- a/app/controllers/concerns/group_tree.rb +++ b/app/controllers/concerns/group_tree.rb @@ -1,21 +1,16 @@ module GroupTree # rubocop:disable Gitlab/ModuleWithInstanceVariables def render_group_tree(groups) - @groups = if params[:filter].present? - # We find the ancestors by ID of the search results here. - # Otherwise the ancestors would also have filters applied, - # which would cause them not to be preloaded. - group_ids = groups.search(params[:filter]).select(:id) - Gitlab::GroupHierarchy.new(Group.where(id: group_ids)) - .base_and_ancestors - else - # Only show root groups if no parent-id is given - groups.where(parent_id: params[:parent_id]) - end + groups = groups.sort_by_attribute(@sort = params[:sort]) - @groups = @groups.with_selects_for_list(archived: params[:archived]) - .sort_by_attribute(@sort = params[:sort]) - .page(params[:page]) + groups = if params[:filter].present? + filtered_groups_with_ancestors(groups) + else + # If `params[:parent_id]` is `nil`, we will only show root-groups + groups.where(parent_id: params[:parent_id]).page(params[:page]) + end + + @groups = groups.with_selects_for_list(archived: params[:archived]) respond_to do |format| format.html @@ -28,4 +23,21 @@ module GroupTree end # rubocop:enable Gitlab/ModuleWithInstanceVariables end + + def filtered_groups_with_ancestors(groups) + filtered_groups = groups.search(params[:filter]).page(params[:page]) + + if Group.supports_nested_groups? + # We find the ancestors by ID of the search results here. + # Otherwise the ancestors would also have filters applied, + # which would cause them not to be preloaded. + # + # Pagination needs to be applied before loading the ancestors to + # make sure ancestors are not cut off by pagination. + Gitlab::GroupHierarchy.new(Group.where(id: filtered_groups.select(:id))) + .base_and_ancestors + else + filtered_groups + end + end end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index ba510968684..37e03d70b6f 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -127,7 +127,7 @@ module IssuableActions errors: [ "Someone edited this #{issuable.human_class_name} at the same time you did. Please refresh your browser and make sure your changes will not unintentionally remove theirs." ] - }, status: 409 + }, status: :conflict end end end diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 5e4e8a87153..79ee5b2f91e 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -27,7 +27,7 @@ module LfsRequest message: 'Git LFS is not enabled on this GitLab server, contact your admin.', documentation_url: help_url }, - status: 501 + status: :not_implemented ) end diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 90bb7a87b45..99123fcb3b0 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -10,9 +10,12 @@ module PreviewMarkdown when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } when 'snippets' then { skip_project_check: true } when 'groups' then { group: group } + when 'projects' then { issuable_state_filter_enabled: true } 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/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 4d4ac025f8c..ccfcbbdc776 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -7,7 +7,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController skip_cross_project_access_check :index, :starred def index - @projects = load_projects(params.merge(non_public: true)).page(params[:page]) + @projects = load_projects(params.merge(non_public: true)) respond_to do |format| format.html @@ -25,7 +25,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController def starred @projects = load_projects(params.merge(starred: true)) - .includes(:forked_from_project, :tags).page(params[:page]) + .includes(:forked_from_project, :tags) @groups = [] @@ -51,6 +51,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController .new(params: finder_params, current_user: current_user) .execute .includes(:route, :creator, namespace: [:route, :owner]) + .page(finder_params[:page]) prepare_projects_for_rendering(projects) end diff --git a/app/controllers/groups/avatars_controller.rb b/app/controllers/groups/avatars_controller.rb index cc5ba5878f8..35a61b359c8 100644 --- a/app/controllers/groups/avatars_controller.rb +++ b/app/controllers/groups/avatars_controller.rb @@ -7,6 +7,6 @@ class Groups::AvatarsController < Groups::ApplicationController @group.remove_avatar! @group.save - redirect_to edit_group_path(@group), status: 302 + redirect_to edit_group_path(@group), status: :found end end diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index 78992ec7f46..1036b4e6ed3 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -23,7 +23,7 @@ class Groups::RunnersController < Groups::ApplicationController def destroy @runner.destroy - redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: 302 + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found end def resume diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 67057b5b126..3cb9e46b548 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -41,7 +41,7 @@ class JwtController < ApplicationController "You must use a personal access token with 'api' scope for Git over HTTP.\n" \ "You can generate one at #{profile_personal_access_tokens_url}" } ] - }, status: 401 + }, status: :unauthorized end def render_unauthorized @@ -50,7 +50,7 @@ class JwtController < ApplicationController { code: 'UNAUTHORIZED', message: 'HTTP Basic: Access denied' } ] - }, status: 401 + }, status: :unauthorized end def auth_params diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb index 8ec4bb1233f..ed20302487c 100644 --- a/app/controllers/notification_settings_controller.rb +++ b/app/controllers/notification_settings_controller.rb @@ -5,14 +5,14 @@ class NotificationSettingsController < ApplicationController return render_404 unless can_read?(resource) @notification_setting = current_user.notification_settings_for(resource) - @saved = @notification_setting.update_attributes(notification_setting_params) + @saved = @notification_setting.update(notification_setting_params) render_response end def update @notification_setting = current_user.notification_settings.find(params[:id]) - @saved = @notification_setting.update_attributes(notification_setting_params) + @saved = @notification_setting.update(notification_setting_params) render_response end diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb index f0cdc228366..f1e77d68acd 100644 --- a/app/controllers/profiles/active_sessions_controller.rb +++ b/app/controllers/profiles/active_sessions_controller.rb @@ -7,7 +7,7 @@ class Profiles::ActiveSessionsController < Profiles::ApplicationController ActiveSession.destroy(current_user, params[:id]) respond_to do |format| - format.html { redirect_to profile_active_sessions_url, status: 302 } + format.html { redirect_to profile_active_sessions_url, status: :found } format.js { head :ok } end end diff --git a/app/controllers/profiles/avatars_controller.rb b/app/controllers/profiles/avatars_controller.rb index 39b9f8a84d1..4f030ded80f 100644 --- a/app/controllers/profiles/avatars_controller.rb +++ b/app/controllers/profiles/avatars_controller.rb @@ -4,6 +4,6 @@ class Profiles::AvatarsController < Profiles::ApplicationController Users::UpdateService.new(current_user, user: @user).execute { |user| user.remove_avatar! } - redirect_to profile_path, status: 302 + redirect_to profile_path, status: :found end end diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb index 2353f0840d6..a186c5f36a8 100644 --- a/app/controllers/profiles/chat_names_controller.rb +++ b/app/controllers/profiles/chat_names_controller.rb @@ -39,7 +39,7 @@ class Profiles::ChatNamesController < Profiles::ApplicationController flash[:alert] = "Could not delete chat nickname #{@chat_name.chat_name}." end - redirect_to profile_chat_names_path, status: 302 + redirect_to profile_chat_names_path, status: :found end private diff --git a/app/controllers/profiles/emails_controller.rb b/app/controllers/profiles/emails_controller.rb index bbd7ba49d77..a39824ec9c8 100644 --- a/app/controllers/profiles/emails_controller.rb +++ b/app/controllers/profiles/emails_controller.rb @@ -19,7 +19,7 @@ class Profiles::EmailsController < Profiles::ApplicationController Emails::DestroyService.new(current_user, user: current_user).execute(@email) respond_to do |format| - format.html { redirect_to profile_emails_url, status: 302 } + format.html { redirect_to profile_emails_url, status: :found } format.js { head :ok } end end diff --git a/app/controllers/profiles/gpg_keys_controller.rb b/app/controllers/profiles/gpg_keys_controller.rb index 38e3eacd229..c32507756e8 100644 --- a/app/controllers/profiles/gpg_keys_controller.rb +++ b/app/controllers/profiles/gpg_keys_controller.rb @@ -21,7 +21,7 @@ class Profiles::GpgKeysController < Profiles::ApplicationController @gpg_key.destroy respond_to do |format| - format.html { redirect_to profile_gpg_keys_url, status: 302 } + format.html { redirect_to profile_gpg_keys_url, status: :found } format.js { head :ok } end end @@ -30,7 +30,7 @@ class Profiles::GpgKeysController < Profiles::ApplicationController @gpg_key.revoke respond_to do |format| - format.html { redirect_to profile_gpg_keys_url, status: 302 } + format.html { redirect_to profile_gpg_keys_url, status: :found } format.js { head :ok } end end diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb index 12a6cd11f80..6035258667e 100644 --- a/app/controllers/profiles/keys_controller.rb +++ b/app/controllers/profiles/keys_controller.rb @@ -26,7 +26,7 @@ class Profiles::KeysController < Profiles::ApplicationController Keys::DestroyService.new(current_user).execute(@key) respond_to do |format| - format.html { redirect_to profile_keys_url, status: 302 } + format.html { redirect_to profile_keys_url, status: :found } format.js { head :ok } end end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index aa9789f8a0f..29ff18a1219 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -78,7 +78,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def destroy current_user.disable_two_factor! - redirect_to profile_account_path, status: 302 + redirect_to profile_account_path, status: :found end def skip diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 5ab6d103c89..b4f814fd3a4 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -61,7 +61,7 @@ class Projects::ApplicationController < ApplicationController def require_non_empty_project # Be sure to return status code 303 to avoid a double DELETE: # http://api.rubyonrails.org/classes/ActionController/Redirecting.html - redirect_to project_path(@project), status: 303 if @project.empty_repo? + redirect_to project_path(@project), status: :see_other if @project.empty_repo? end def require_branch_head diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 992c8ea6992..07627ffb69f 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -14,7 +14,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController end def labels - render json: @autocomplete_service.labels(target) + render json: @autocomplete_service.labels_as_hash(target) end def milestones diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb index 21a403f3765..a13d552dbd8 100644 --- a/app/controllers/projects/avatars_controller.rb +++ b/app/controllers/projects/avatars_controller.rb @@ -21,6 +21,6 @@ class Projects::AvatarsController < Projects::ApplicationController @project.save - redirect_to edit_project_path(@project), status: 302 + redirect_to edit_project_path(@project), status: :found end end diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index cd7250b10fc..d1dc9fe9600 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -98,7 +98,7 @@ class Projects::BranchesController < Projects::ApplicationController flash_type = result[:status] == :error ? :alert : :notice flash[flash_type] = result[:message] - redirect_to project_branches_path(@project), status: 303 + redirect_to project_branches_path(@project), status: :see_other end format.js { render nothing: true, status: result[:return_code] } diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index 62193257940..358fe59618b 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -62,7 +62,7 @@ class Projects::ClustersController < Projects::ApplicationController def destroy if cluster.destroy flash[:notice] = _('Kubernetes cluster integration was successfully removed.') - redirect_to project_clusters_path(project), status: 302 + redirect_to project_clusters_path(project), status: :found else flash[:notice] = _('Kubernetes cluster integration was not removed.') render :show diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index f43ef2e5f2f..06739d8fd4a 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -35,7 +35,7 @@ class Projects::DeployKeysController < Projects::ApplicationController end def update - if deploy_key.update_attributes(update_params) + if deploy_key.update(update_params) flash[:notice] = 'Deploy key was successfully updated.' redirect_to_repository_settings(@project) else diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 27b7425b965..395c5336ad5 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -116,7 +116,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController set_workhorse_internal_api_content_type render json: Gitlab::Workhorse.terminal_websocket(terminal) else - render text: 'Not found', status: 404 + render text: 'Not found', status: :not_found end end diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb index 07249fe3182..a52814e6e52 100644 --- a/app/controllers/projects/git_http_client_controller.rb +++ b/app/controllers/projects/git_http_client_controller.rb @@ -53,7 +53,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController end send_challenges - render plain: "HTTP Basic: Access denied\n", status: 401 + render plain: "HTTP Basic: Access denied\n", status: :unauthorized rescue Gitlab::Auth::MissingPersonalAccessTokenError render_missing_personal_access_token end @@ -83,7 +83,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController render plain: "HTTP Basic: Access denied\n" \ "You must use a personal access token with 'api' scope for Git over HTTP.\n" \ "You can generate one at #{profile_personal_access_tokens_url}", - status: 401 + status: :unauthorized end def repository diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index f58ee3e9109..bc5f38f3c2b 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -24,7 +24,7 @@ class Projects::GroupLinksController < Projects::ApplicationController def update @group_link = @project.project_group_links.find(params[:id]) - @group_link.update_attributes(group_link_params) + @group_link.update(group_link_params) end def destroy @@ -34,7 +34,7 @@ class Projects::GroupLinksController < Projects::ApplicationController respond_to do |format| format.html do - redirect_to project_project_members_path(project), status: 302 + redirect_to project_project_members_path(project), status: :found end format.js { head :ok } end diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index 6800d742b0a..2da2aad9b33 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -29,7 +29,7 @@ class Projects::HooksController < Projects::ApplicationController end def update - if hook.update_attributes(hook_params) + if hook.update(hook_params) flash[:notice] = 'Hook was successfully updated.' redirect_to project_settings_integrations_path(@project) else @@ -48,7 +48,7 @@ class Projects::HooksController < Projects::ApplicationController def destroy hook.destroy - redirect_to project_settings_integrations_path(@project), status: 302 + redirect_to project_settings_integrations_path(@project), status: :found end private diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 91016f6494e..21d3c918581 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -39,7 +39,7 @@ class Projects::LabelsController < Projects::ApplicationController else respond_to do |format| format.html { render :new } - format.json { render json: { message: @label.errors.messages }, status: 400 } + format.json { render json: { message: @label.errors.messages }, status: :bad_request } end end end @@ -115,7 +115,7 @@ class Projects::LabelsController < Projects::ApplicationController flash[:notice] = "#{@label.title} promoted to <a href=\"#{group_labels_path(@project.group)}\">group label</a>.".html_safe respond_to do |format| format.html do - redirect_to(project_labels_path(@project), status: 303) + redirect_to(project_labels_path(@project), status: :see_other) end format.json do render json: { url: project_labels_path(@project) } diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index 3f4962b543d..c64ccc3d473 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -25,7 +25,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController message: 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.', documentation_url: "#{Gitlab.config.gitlab.url}/help" }, - status: 501 + status: :not_implemented ) end diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb index 45c98d60822..dd7e673ec75 100644 --- a/app/controllers/projects/lfs_storage_controller.rb +++ b/app/controllers/projects/lfs_storage_controller.rb @@ -28,7 +28,7 @@ class Projects::LfsStorageController < Projects::GitHttpClientController if store_file!(oid, size) head 200 else - render plain: 'Unprocessable entity', status: 422 + render plain: 'Unprocessable entity', status: :unprocessable_entity end rescue ActiveRecord::RecordInvalid render_lfs_forbidden diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a7c5f858c42..1ad2e93c85f 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -227,7 +227,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def rebase RebaseWorker.perform_async(@merge_request.id, current_user.id) - render nothing: true, status: 200 + render nothing: true, status: :ok end protected diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 594563d1f6f..5e86ec93f34 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -96,7 +96,7 @@ class Projects::MilestonesController < Projects::ApplicationController Milestones::DestroyService.new(project, current_user).execute(milestone) respond_to do |format| - format.html { redirect_to namespace_project_milestones_path, status: 303 } + format.html { redirect_to namespace_project_milestones_path, status: :see_other } format.js { head :ok } end end diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb index 5698ff4e706..3b24d231f3d 100644 --- a/app/controllers/projects/mirrors_controller.rb +++ b/app/controllers/projects/mirrors_controller.rb @@ -13,7 +13,7 @@ class Projects::MirrorsController < Projects::ApplicationController end def update - if project.update_attributes(mirror_params) + if project.update(mirror_params) flash[:notice] = 'Mirroring settings were successfully updated.' else flash[:alert] = project.errors.full_messages.join(', ').html_safe diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index fa258f3d9af..aeda7b3edf5 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -64,7 +64,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController def destroy if schedule.destroy - redirect_to pipeline_schedules_path(@project), status: 302 + redirect_to pipeline_schedules_path(@project), status: :found else redirect_to pipeline_schedules_path(@project), status: :forbidden, diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 3e0a530fdb9..19e09b3af6f 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -14,7 +14,7 @@ class Projects::ReleasesController < Projects::ApplicationController # it exists only to save a description to each Tag. # If description is empty we should destroy the existing record. if release_params[:description].present? - release.update_attributes(release_params) + release.update(release_params) else release.destroy end diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb index d01f324e6fd..ecb2ece7532 100644 --- a/app/controllers/projects/repositories_controller.rb +++ b/app/controllers/projects/repositories_controller.rb @@ -24,7 +24,7 @@ class Projects::RepositoriesController < Projects::ApplicationController send_git_archive @repository, ref: @ref, format: params[:format], append_sha: append_sha rescue => ex logger.error("#{self.class.name}: #{ex}") - return git_not_found! + git_not_found! end def assign_archive_vars diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb index a080724634b..c098c82081e 100644 --- a/app/controllers/projects/runner_projects_controller.rb +++ b/app/controllers/projects/runner_projects_controller.rb @@ -21,6 +21,6 @@ class Projects::RunnerProjectsController < Projects::ApplicationController runner_project = project.runner_projects.find(params[:id]) runner_project.destroy - redirect_to project_runners_path(project), status: 302 + redirect_to project_runners_path(project), status: :found end end diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index bef94cea989..cc7cce887bf 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -24,7 +24,7 @@ class Projects::RunnersController < Projects::ApplicationController @runner.destroy end - redirect_to project_runners_path(@project), status: 302 + redirect_to project_runners_path(@project), status: :found end def resume diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index 690596b12db..d55046047ae 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -34,7 +34,7 @@ class Projects::ServicesController < Projects::ApplicationController private def service_test_response - if @service.update_attributes(service_params[:service]) + if @service.update(service_params[:service]) data = @service.test_data(project, current_user) outcome = @service.test(data) diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index 208a1d19862..f742d7edf83 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -82,7 +82,7 @@ class Projects::SnippetsController < Projects::ApplicationController @snippet.destroy - redirect_to project_snippets_path(@project), status: 302 + redirect_to project_snippets_path(@project), status: :found end protected diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index b62d7d9b7c5..b17753222a0 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -50,7 +50,7 @@ class Projects::TagsController < Projects::ApplicationController respond_to do |format| if result[:status] == :success format.html do - redirect_to project_tags_path(@project), status: 303 + redirect_to project_tags_path(@project), status: :see_other end format.js diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb index 694b468c8d3..52d6fb82093 100644 --- a/app/controllers/projects/templates_controller.rb +++ b/app/controllers/projects/templates_controller.rb @@ -14,6 +14,6 @@ class Projects::TemplatesController < Projects::ApplicationController def get_template_class template_types = { issue: Gitlab::Template::IssueTemplate, merge_request: Gitlab::Template::MergeRequestTemplate }.with_indifferent_access @template_type = template_types[params[:template_type]] - render json: [], status: 404 unless @template_type + render json: [], status: :not_found unless @template_type end end diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb index e04145dd0b3..6f3de43f85a 100644 --- a/app/controllers/projects/triggers_controller.rb +++ b/app/controllers/projects/triggers_controller.rb @@ -50,7 +50,7 @@ class Projects::TriggersController < Projects::ApplicationController flash[:alert] = "Could not remove the trigger." end - redirect_to project_settings_ci_cd_path(@project), status: 302 + redirect_to project_settings_ci_cd_path(@project), status: :found end private diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index aa844e94d89..c01066c688a 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -120,7 +120,7 @@ class Projects::WikisController < Projects::ApplicationController rescue ProjectWiki::CouldNotCreateWikiError flash[:notice] = "Could not create Wiki Repository at this time. Please try again later." redirect_to project_path(@project) - return false + false end def wiki_params @@ -129,7 +129,7 @@ class Projects::WikisController < Projects::ApplicationController def build_page(args) WikiPage.new(@project_wiki).tap do |page| - page.update_attributes(args) + page.update_attributes(args) # rubocop:disable Rails/ActiveRecordAliases end end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index ec3a5788ba1..9d1c44db137 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -2,6 +2,7 @@ class ProjectsController < Projects::ApplicationController include IssuableCollections include ExtractsPath include PreviewMarkdown + include SendFileUpload before_action :whitelist_query_limiting, only: [:create] before_action :authenticate_user!, except: [:index, :show, :activity, :refs] @@ -132,7 +133,7 @@ class ProjectsController < Projects::ApplicationController ::Projects::DestroyService.new(@project, current_user, {}).async_execute flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.full_name } - redirect_to dashboard_projects_path, status: 302 + redirect_to dashboard_projects_path, status: :found rescue Projects::DestroyService::DestroyError => ex redirect_to edit_project_path(@project), status: 302, alert: ex.message end @@ -188,9 +189,9 @@ class ProjectsController < Projects::ApplicationController end def download_export - export_project_path = @project.export_project_path - - if export_project_path + if export_project_object_storage? + send_upload(@project.import_export_upload.export_file) + elsif export_project_path send_file export_project_path, disposition: 'attachment' else redirect_to( @@ -265,8 +266,6 @@ class ProjectsController < Projects::ApplicationController render json: options.to_json end - private - # Render project landing depending of which features are available # So if page is not availble in the list it renders the next page # @@ -424,4 +423,12 @@ class ProjectsController < Projects::ApplicationController def whitelist_query_limiting Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42440') end + + def export_project_path + @export_project_path ||= @project.export_project_path + end + + def export_project_object_storage? + @project.export_project_object_exists? + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 1de6ae24622..9dd652206fe 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -32,8 +32,8 @@ class SessionsController < Devise::SessionsController super do |resource| # User has successfully signed in, so clear any unused reset token if resource.reset_password_token.present? - resource.update_attributes(reset_password_token: nil, - reset_password_sent_at: nil) + resource.update(reset_password_token: nil, + reset_password_sent_at: nil) end # hide the signed-in notification diff --git a/app/controllers/sherlock/transactions_controller.rb b/app/controllers/sherlock/transactions_controller.rb index cb6c3a7cd98..ae4953c3259 100644 --- a/app/controllers/sherlock/transactions_controller.rb +++ b/app/controllers/sherlock/transactions_controller.rb @@ -13,7 +13,7 @@ module Sherlock def destroy_all Gitlab::Sherlock.collection.clear - redirect_to :back, status: 302 + redirect_to :back, status: :found end end end diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index 3d51520ddf4..1d6d0943674 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -89,7 +89,7 @@ class SnippetsController < ApplicationController @snippet.destroy - redirect_to snippets_path, status: 302 + redirect_to snippets_path, status: :found end protected diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 95fea2f18d1..3c5c8bbd71b 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -128,8 +128,10 @@ module GroupsHelper def get_group_sidebar_links links = [:overview, :group_members] - if can?(current_user, :read_cross_project) - links += [:activity, :issues, :boards, :labels, :milestones, :merge_requests] + resources = [:activity, :issues, :boards, :labels, :milestones, + :merge_requests] + links += resources.select do |resource| + can?(current_user, "read_group_#{resource}".to_sym, @group) end if can?(current_user, :admin_group, @group) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 353479776b8..7bbdc798ddd 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -249,6 +249,7 @@ module IssuablesHelper issuableRef: issuable.to_reference, markdownPreviewPath: preview_markdown_path(parent), markdownDocsPath: help_page_path('user/markdown'), + markdownVersion: issuable.cached_markdown_version, issuableTemplates: issuable_templates(issuable), initialTitleHtml: markdown_field(issuable, :title), initialTitleText: issuable.title, diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 39e7a7fd396..cbb971cf8b7 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -107,6 +107,7 @@ module MarkupHelper def markup(file_name, text, context = {}) context[:project] ||= @project + context[:markdown_engine] ||= :redcarpet html = context.delete(:rendered) || markup_unsafe(file_name, text, context) prepare_for_rendering(html, context) end @@ -120,7 +121,8 @@ module MarkupHelper project: @project, project_wiki: @project_wiki, page_slug: wiki_page.slug, - issuable_state_filter_enabled: true + issuable_state_filter_enabled: true, + markdown_engine: :redcarpet } html = diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 3fa2e5452c8..5404ead44f3 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -169,6 +169,7 @@ module NotesHelper registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'), markdownDocsPath: help_page_path('user/markdown'), + markdownVersion: issuable.cached_markdown_version, quickActionsDocsPath: help_page_path('user/project/quick_actions'), closePath: close_issuable_path(issuable), reopenPath: reopen_issuable_path(issuable), diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c7a434ea092..b0f381db5ab 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -177,6 +177,7 @@ module ProjectsHelper controller.action_name, Gitlab::CurrentSettings.cache_key, "cross-project:#{can?(current_user, :read_cross_project)}", + max_project_member_access_cache_key(project), 'v2.6' ] diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index ce9373f5883..4d17b22a4a1 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -31,6 +31,14 @@ module UsersHelper current_user_menu_items.include?(item) end + def max_project_member_access(project) + current_user&.max_member_access_for_project(project.id) || Gitlab::Access::NO_ACCESS + end + + def max_project_member_access_cache_key(project) + "access:#{max_project_member_access(project)}" + end + private def get_profile_tabs diff --git a/app/mailers/previews/devise_mailer_preview.rb b/app/mailers/previews/devise_mailer_preview.rb new file mode 100644 index 00000000000..d6588efc486 --- /dev/null +++ b/app/mailers/previews/devise_mailer_preview.rb @@ -0,0 +1,30 @@ +class DeviseMailerPreview < ActionMailer::Preview + def confirmation_instructions_for_signup + DeviseMailer.confirmation_instructions(unsaved_user, 'faketoken', {}) + end + + def confirmation_instructions_for_new_email + user = User.last + user.unconfirmed_email = 'unconfirmed@example.com' + + DeviseMailer.confirmation_instructions(user, 'faketoken', {}) + end + + def reset_password_instructions + DeviseMailer.reset_password_instructions(unsaved_user, 'faketoken', {}) + end + + def unlock_instructions + DeviseMailer.unlock_instructions(unsaved_user, 'faketoken', {}) + end + + def password_change + DeviseMailer.password_change(unsaved_user, {}) + end + + private + + def unsaved_user + User.new(name: 'Jane Doe', email: 'jdoe@example.com') + end +end diff --git a/app/mailers/previews/email_rejection_mailer_preview.rb b/app/mailers/previews/email_rejection_mailer_preview.rb new file mode 100644 index 00000000000..639e8471232 --- /dev/null +++ b/app/mailers/previews/email_rejection_mailer_preview.rb @@ -0,0 +1,5 @@ +class EmailRejectionMailerPreview < ActionMailer::Preview + def rejection + EmailRejectionMailer.rejection("some rejection reason", "From: someone@example.com\nraw email here").message + end +end diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb new file mode 100644 index 00000000000..3615cde8026 --- /dev/null +++ b/app/mailers/previews/notify_preview.rb @@ -0,0 +1,170 @@ +class NotifyPreview < ActionMailer::Preview + def note_merge_request_email_for_individual_note + note_email(:note_merge_request_email) do + note = <<-MD.strip_heredoc + This is an individual note on a merge request :smiley: + + In this notification email, we expect to see: + + - The note contents (that's what you're looking at) + - A link to view this note on Gitlab + - An explanation for why the user is receiving this notification + MD + + create_note(noteable_type: 'merge_request', noteable_id: merge_request.id, note: note) + end + end + + def note_merge_request_email_for_discussion + note_email(:note_merge_request_email) do + note = <<-MD.strip_heredoc + This is a new discussion on a merge request :smiley: + + In this notification email, we expect to see: + + - A line saying who started this discussion + - The note contents (that's what you're looking at) + - A link to view this discussion on Gitlab + - An explanation for why the user is receiving this notification + MD + + create_note(noteable_type: 'merge_request', noteable_id: merge_request.id, type: 'DiscussionNote', note: note) + end + end + + def note_merge_request_email_for_diff_discussion + note_email(:note_merge_request_email) do + note = <<-MD.strip_heredoc + This is a new discussion on a merge request :smiley: + + In this notification email, we expect to see: + + - A line saying who started this discussion and on what file + - The diff + - The note contents (that's what you're looking at) + - A link to view this discussion on Gitlab + - An explanation for why the user is receiving this notification + MD + + position = Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 14, + diff_refs: merge_request.diff_refs + ) + + create_note(noteable_type: 'merge_request', noteable_id: merge_request.id, type: 'DiffNote', position: position, note: note) + end + end + + def closed_issue_email + Notify.closed_issue_email(user.id, issue.id, user.id).message + end + + def issue_status_changed_email + Notify.issue_status_changed_email(user.id, issue.id, 'closed', user.id).message + end + + def closed_merge_request_email + Notify.closed_merge_request_email(user.id, issue.id, user.id).message + end + + def merge_request_status_email + Notify.merge_request_status_email(user.id, merge_request.id, 'closed', user.id).message + end + + def merged_merge_request_email + Notify.merged_merge_request_email(user.id, merge_request.id, user.id).message + end + + def member_access_denied_email + Notify.member_access_denied_email('project', project.id, user.id).message + end + + def member_access_granted_email + Notify.member_access_granted_email('project', user.id).message + end + + def member_access_requested_email + Notify.member_access_requested_email('group', user.id, 'some@example.com').message + end + + def member_invite_accepted_email + Notify.member_invite_accepted_email('project', user.id).message + end + + def member_invite_declined_email + Notify.member_invite_declined_email( + 'project', + project.id, + 'invite@example.com', + user.id + ).message + end + + def member_invited_email + Notify.member_invited_email('project', user.id, '1234').message + end + + def pages_domain_enabled_email + cleanup do + pages_domain = PagesDomain.new(domain: 'my.example.com', project: project, verified_at: Time.now, enabled_until: 1.week.from_now) + + Notify.pages_domain_enabled_email(pages_domain, user).message + end + end + + def pipeline_success_email + Notify.pipeline_success_email(pipeline, pipeline.user.try(:email)) + end + + def pipeline_failed_email + Notify.pipeline_failed_email(pipeline, pipeline.user.try(:email)) + end + + private + + def project + @project ||= Project.find_by_full_path('gitlab-org/gitlab-test') + end + + def issue + @merge_request ||= project.issues.first + end + + def merge_request + @merge_request ||= project.merge_requests.first + end + + def pipeline + @pipeline = Ci::Pipeline.last + end + + def user + @user ||= User.last + end + + def create_note(params) + Notes::CreateService.new(project, user, params).execute + end + + def note_email(method) + cleanup do + note = yield + + Notify.public_send(method, user.id, note) # rubocop:disable GitlabSecurity/PublicSend + end + end + + def cleanup + email = nil + + ActiveRecord::Base.transaction do + email = yield + raise ActiveRecord::Rollback + end + + email + end +end diff --git a/app/mailers/previews/repository_check_mailer_preview.rb b/app/mailers/previews/repository_check_mailer_preview.rb new file mode 100644 index 00000000000..19d4eab1805 --- /dev/null +++ b/app/mailers/previews/repository_check_mailer_preview.rb @@ -0,0 +1,5 @@ +class RepositoryCheckMailerPreview < ActionMailer::Preview + def notify + RepositoryCheckMailer.notify(3).message + end +end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index bf93a2caf72..44103e3bc4f 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -371,7 +371,7 @@ module Ci def update_coverage coverage = trace.extract_coverage(coverage_regex) - update_attributes(coverage: coverage) if coverage.present? + update(coverage: coverage) if coverage.present? end def parse_trace_sections! @@ -386,6 +386,10 @@ module Ci trace.exist? end + def has_old_trace? + old_trace.present? + end + def trace=(data) raise NotImplementedError end @@ -395,6 +399,8 @@ module Ci end def erase_old_trace! + return unless has_old_trace? + update_column(:trace, nil) end diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 4856f10846c..b442de34061 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -1,54 +1,58 @@ module Ci class BuildTraceChunk < ActiveRecord::Base include FastDestroyAll + include ::Gitlab::ExclusiveLeaseHelpers extend Gitlab::Ci::Model belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id default_value_for :data_store, :redis - WriteError = Class.new(StandardError) - CHUNK_SIZE = 128.kilobytes - CHUNK_REDIS_TTL = 1.week WRITE_LOCK_RETRY = 10 WRITE_LOCK_SLEEP = 0.01.seconds WRITE_LOCK_TTL = 1.minute + # Note: The ordering of this enum is related to the precedence of persist store. + # The bottom item takes the higest precedence, and the top item takes the lowest precedence. enum data_store: { redis: 1, - db: 2 + database: 2, + fog: 3 } class << self - def redis_data_key(build_id, chunk_index) - "gitlab:ci:trace:#{build_id}:chunks:#{chunk_index}" + def all_stores + @all_stores ||= self.data_stores.keys end - def redis_data_keys - redis.pluck(:build_id, :chunk_index).map do |data| - redis_data_key(data.first, data.second) - end + def persistable_store + # get first available store from the back of the list + all_stores.reverse.find { |store| get_store_class(store).available? } end - def redis_delete_data(keys) - return if keys.empty? - - Gitlab::Redis::SharedState.with do |redis| - redis.del(keys) - end + def get_store_class(store) + @stores ||= {} + @stores[store] ||= "Ci::BuildTraceChunks::#{store.capitalize}".constantize.new end ## # FastDestroyAll concerns def begin_fast_destroy - redis_data_keys + all_stores.each_with_object({}) do |store, result| + relation = public_send(store) # rubocop:disable GitlabSecurity/PublicSend + keys = get_store_class(store).keys(relation) + + result[store] = keys if keys.present? + end end ## # FastDestroyAll concerns def finalize_fast_destroy(keys) - redis_delete_data(keys) + keys.each do |store, value| + get_store_class(store).delete_keys(value) + end end end @@ -66,10 +70,15 @@ module Ci end def append(new_data, offset) + raise ArgumentError, 'New data is missing' unless new_data raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0 raise ArgumentError, 'Chunk size overflow' if CHUNK_SIZE < (offset + new_data.bytesize) - set_data(data.byteslice(0, offset) + new_data) + in_lock(*lock_params) do # Write opetation is atomic + unsafe_set_data!(data.byteslice(0, offset) + new_data) + end + + schedule_to_persist if full? end def size @@ -88,93 +97,63 @@ module Ci (start_offset...end_offset) end - def use_database! - in_lock do - break if db? - break unless size > 0 - - self.update!(raw_data: data, data_store: :db) - self.class.redis_delete_data([redis_data_key]) + def persist_data! + in_lock(*lock_params) do # Write opetation is atomic + unsafe_persist_to!(self.class.persistable_store) end end private - def get_data - if redis? - redis_data - elsif db? - raw_data - else - raise 'Unsupported data store' - end&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default - end - - def set_data(value) - raise ArgumentError, 'too much data' if value.bytesize > CHUNK_SIZE - - in_lock do - if redis? - redis_set_data(value) - elsif db? - self.raw_data = value - else - raise 'Unsupported data store' - end + def unsafe_persist_to!(new_store) + return if data_store == new_store.to_s + raise ArgumentError, 'Can not persist empty data' unless size > 0 - @data = value + old_store_class = self.class.get_store_class(data_store) - save! if changed? + get_data.tap do |the_data| + self.raw_data = nil + self.data_store = new_store + unsafe_set_data!(the_data) end - schedule_to_db if full? - end - - def schedule_to_db - return if db? - - Ci::BuildTraceChunkFlushWorker.perform_async(id) + old_store_class.delete_data(self) end - def full? - size == CHUNK_SIZE + def get_data + self.class.get_store_class(data_store).data(self)&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default + rescue Excon::Error::NotFound + # If the data store is :fog and the file does not exist in the object storage, this method returns nil. end - def redis_data - Gitlab::Redis::SharedState.with do |redis| - redis.get(redis_data_key) - end - end + def unsafe_set_data!(value) + raise ArgumentError, 'New data size exceeds chunk size' if value.bytesize > CHUNK_SIZE - def redis_set_data(data) - Gitlab::Redis::SharedState.with do |redis| - redis.set(redis_data_key, data, ex: CHUNK_REDIS_TTL) - end - end + self.class.get_store_class(data_store).set_data(self, value) + @data = value - def redis_data_key - self.class.redis_data_key(build_id, chunk_index) + save! if changed? end - def in_lock - write_lock_key = "trace_write:#{build_id}:chunks:#{chunk_index}" + def schedule_to_persist + return if data_persisted? - lease = Gitlab::ExclusiveLease.new(write_lock_key, timeout: WRITE_LOCK_TTL) - retry_count = 0 + Ci::BuildTraceChunkFlushWorker.perform_async(id) + end - until uuid = lease.try_obtain - # Keep trying until we obtain the lease. To prevent hammering Redis too - # much we'll wait for a bit between retries. - sleep(WRITE_LOCK_SLEEP) - break if WRITE_LOCK_RETRY < (retry_count += 1) - end + def data_persisted? + !redis? + end - raise WriteError, 'Failed to obtain write lock' unless uuid + def full? + size == CHUNK_SIZE + end - self.reload if self.persisted? - return yield - ensure - Gitlab::ExclusiveLease.cancel(write_lock_key, uuid) + def lock_params + ["trace_write:#{build_id}:chunks:#{chunk_index}", + { ttl: WRITE_LOCK_TTL, + retries: WRITE_LOCK_RETRY, + sleep_sec: WRITE_LOCK_SLEEP }] end end end diff --git a/app/models/ci/build_trace_chunks/database.rb b/app/models/ci/build_trace_chunks/database.rb new file mode 100644 index 00000000000..3666d77c790 --- /dev/null +++ b/app/models/ci/build_trace_chunks/database.rb @@ -0,0 +1,29 @@ +module Ci + module BuildTraceChunks + class Database + def available? + true + end + + def keys(relation) + [] + end + + def delete_keys(keys) + # no-op + end + + def data(model) + model.raw_data + end + + def set_data(model, data) + model.raw_data = data + end + + def delete_data(model) + model.update_columns(raw_data: nil) unless model.raw_data.nil? + end + end + end +end diff --git a/app/models/ci/build_trace_chunks/fog.rb b/app/models/ci/build_trace_chunks/fog.rb new file mode 100644 index 00000000000..7506c40a39d --- /dev/null +++ b/app/models/ci/build_trace_chunks/fog.rb @@ -0,0 +1,59 @@ +module Ci + module BuildTraceChunks + class Fog + def available? + object_store.enabled + end + + def data(model) + connection.get_object(bucket_name, key(model))[:body] + end + + def set_data(model, data) + connection.put_object(bucket_name, key(model), data) + end + + def delete_data(model) + delete_keys([[model.build_id, model.chunk_index]]) + end + + def keys(relation) + return [] unless available? + + relation.pluck(:build_id, :chunk_index) + end + + def delete_keys(keys) + keys.each do |key| + connection.delete_object(bucket_name, key_raw(*key)) + end + end + + private + + def key(model) + key_raw(model.build_id, model.chunk_index) + end + + def key_raw(build_id, chunk_index) + "tmp/builds/#{build_id.to_i}/chunks/#{chunk_index.to_i}.log" + end + + def bucket_name + return unless available? + + object_store.remote_directory + end + + def connection + return unless available? + + @connection ||= ::Fog::Storage.new(object_store.connection.to_hash.deep_symbolize_keys) + end + + def object_store + Gitlab.config.artifacts.object_store + end + end + end +end diff --git a/app/models/ci/build_trace_chunks/redis.rb b/app/models/ci/build_trace_chunks/redis.rb new file mode 100644 index 00000000000..fdb6065e2a0 --- /dev/null +++ b/app/models/ci/build_trace_chunks/redis.rb @@ -0,0 +1,51 @@ +module Ci + module BuildTraceChunks + class Redis + CHUNK_REDIS_TTL = 1.week + + def available? + true + end + + def data(model) + Gitlab::Redis::SharedState.with do |redis| + redis.get(key(model)) + end + end + + def set_data(model, data) + Gitlab::Redis::SharedState.with do |redis| + redis.set(key(model), data, ex: CHUNK_REDIS_TTL) + end + end + + def delete_data(model) + delete_keys([[model.build_id, model.chunk_index]]) + end + + def keys(relation) + relation.pluck(:build_id, :chunk_index) + end + + def delete_keys(keys) + return if keys.empty? + + keys = keys.map { |key| key_raw(*key) } + + Gitlab::Redis::SharedState.with do |redis| + redis.del(keys) + end + end + + private + + def key(model) + key_raw(model.build_id, model.chunk_index) + end + + def key_raw(build_id, chunk_index) + "gitlab:ci:trace:#{build_id.to_i}:chunks:#{chunk_index.to_i}" + end + end + end +end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 9f6358cecbe..b05bf909058 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -40,6 +40,18 @@ module CacheMarkdownField end end + class MarkdownEngine + def self.from_version(version = nil) + return :common_mark if version.nil? || version == 0 + + if version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START + :redcarpet + else + :common_mark + end + end + end + def skip_project_check? false end @@ -57,7 +69,7 @@ module CacheMarkdownField # Banzai is less strict about authors, so don't always have an author key context[:author] = self.author if self.respond_to?(:author) - context[:markdown_engine] = markdown_engine + context[:markdown_engine] = MarkdownEngine.from_version(latest_cached_markdown_version) context end @@ -123,14 +135,6 @@ module CacheMarkdownField end end - def markdown_engine - if latest_cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - :redcarpet - else - :common_mark - end - end - included do cattr_reader :cached_markdown_fields do FieldData.new diff --git a/app/models/concerns/cacheable_attributes.rb b/app/models/concerns/cacheable_attributes.rb index d58d7165969..606549b947f 100644 --- a/app/models/concerns/cacheable_attributes.rb +++ b/app/models/concerns/cacheable_attributes.rb @@ -7,7 +7,7 @@ module CacheableAttributes class_methods do def cache_key - "#{name}:#{Gitlab::VERSION}:#{Gitlab.migrations_hash}:#{Rails.version}".freeze + "#{name}:#{Gitlab::VERSION}:#{Rails.version}".freeze end # Can be overriden @@ -69,6 +69,6 @@ module CacheableAttributes end def cache! - Rails.cache.write(self.class.cache_key, self) + Rails.cache.write(self.class.cache_key, self, expires_in: 1.minute) end end diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb index 261ace57a17..5e9a95c3282 100644 --- a/app/models/concerns/group_descendant.rb +++ b/app/models/concerns/group_descendant.rb @@ -44,8 +44,8 @@ module GroupDescendant This error is not user facing, but causes a +1 query. MSG extras = { - parent: parent, - child: child, + parent: parent.inspect, + child: child.inspect, preloaded: preloaded.map(&:full_path) } issue_url = 'https://gitlab.com/gitlab-org/gitlab-ce/issues/40785' diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index 94eef4ff7cd..dbe8d31de37 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -23,7 +23,7 @@ module ProtectedRef # If we don't `protected_branch` or `protected_tag` would be empty and # `project` cannot be delegated to it, which in turn would cause validations # to fail. - has_many :"#{type}_access_levels", inverse_of: self.model_name.singular # rubocop:disable Cop/ActiveRecordDependent + has_many :"#{type}_access_levels", inverse_of: self.model_name.singular validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." } diff --git a/app/models/import_export_upload.rb b/app/models/import_export_upload.rb new file mode 100644 index 00000000000..60d53d6c2c8 --- /dev/null +++ b/app/models/import_export_upload.rb @@ -0,0 +1,13 @@ +class ImportExportUpload < ActiveRecord::Base + include WithUploads + include ObjectStorage::BackgroundMove + + belongs_to :project + + mount_uploader :import_file, ImportExportUploader + mount_uploader :export_file, ImportExportUploader + + def retrieve_upload(_identifier, paths) + Upload.find_by(model: self, path: paths) + end +end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index d05dcfd083a..14cc12b38a5 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -131,9 +131,10 @@ class Milestone < ActiveRecord::Base rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id') else rel - .group(:project_id) + .group(:project_id, :due_date, :id) .having('due_date = MIN(due_date)') .pluck(:id, :project_id, :due_date) + .uniq(&:second) .map(&:first) end end diff --git a/app/models/network/commit.rb b/app/models/network/commit.rb index 22d48c9e661..d667948deae 100644 --- a/app/models/network/commit.rb +++ b/app/models/network/commit.rb @@ -11,8 +11,8 @@ module Network @parent_spaces = [] end - def method_missing(m, *args, &block) - @commit.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend + def method_missing(msg, *args, &block) + @commit.__send__(msg, *args, &block) # rubocop:disable GitlabSecurity/PublicSend end def space diff --git a/app/models/project.rb b/app/models/project.rb index 8f40470de82..770262f6193 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -171,6 +171,7 @@ class Project < ActiveRecord::Base has_one :fork_network, through: :fork_network_member has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project + has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id' @@ -1712,7 +1713,7 @@ class Project < ActiveRecord::Base :started elsif after_export_in_progress? :after_export_action - elsif export_project_path + elsif export_project_path || export_project_object_exists? :finished else :none @@ -1727,16 +1728,21 @@ class Project < ActiveRecord::Base import_export_shared.after_export_in_progress? end - def remove_exports - return nil unless export_path.present? - - FileUtils.rm_rf(export_path) + def remove_exports(path = export_path) + if path.present? + FileUtils.rm_rf(path) + elsif export_project_object_exists? + import_export_upload.remove_export_file! + import_export_upload.save + end end def remove_exported_project_file - return unless export_project_path.present? + remove_exports(export_project_path) + end - FileUtils.rm_f(export_project_path) + def export_project_object_exists? + Gitlab::ImportExport.object_storage? && import_export_upload&.export_file&.file end def full_path_slug diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index a6f94b3e3b0..fc868c3ebb7 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -107,7 +107,7 @@ class ProjectWiki update_project_activity rescue Gitlab::Git::Wiki::DuplicatePageError => e @error_message = "Duplicate page: #{e.message}" - return false + false end def update_page(page, content:, title: nil, format: :markdown, message: nil) diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index c4b5dd2dc96..976b501e297 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -57,7 +57,7 @@ class RemoteMirror < ActiveRecord::Base Gitlab::Metrics.add_event(:remote_mirrors_finished, path: remote_mirror.project.full_path) timestamp = Time.now - remote_mirror.update_attributes!( + remote_mirror.update!( last_update_at: timestamp, last_successful_update_at: timestamp, last_error: nil ) end diff --git a/app/models/repository.rb b/app/models/repository.rb index 5f9894f1168..5ed2a7b4068 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -83,7 +83,7 @@ class Repository @raw_repository&.cleanup end - # Return absolute path to repository + # Don't use this! It's going away. Use Gitaly to read or write from repos. def path_to_repo @path_to_repo ||= begin @@ -250,7 +250,7 @@ class Repository # This will still fail if the file is corrupted (e.g. 0 bytes) raw_repository.write_ref(keep_around_ref_name(sha), sha, shell: false) rescue Gitlab::Git::CommandError => ex - Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}" + Rails.logger.error "Unable to create keep-around reference for repository #{disk_path}: #{ex}" end def kept_around?(sha) @@ -462,12 +462,12 @@ class Repository expire_branches_cache end - def method_missing(m, *args, &block) - if m == :lookup && !block_given? - lookup_cache[m] ||= {} - lookup_cache[m][args.join(":")] ||= raw_repository.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend + def method_missing(msg, *args, &block) + if msg == :lookup && !block_given? + lookup_cache[msg] ||= {} + lookup_cache[msg][args.join(":")] ||= raw_repository.__send__(msg, *args, &block) # rubocop:disable GitlabSecurity/PublicSend else - raw_repository.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend + raw_repository.__send__(msg, *args, &block) # rubocop:disable GitlabSecurity/PublicSend end end @@ -564,7 +564,7 @@ class Repository end def rendered_readme - MarkupHelper.markup_unsafe(readme.name, readme.data, project: project) if readme + MarkupHelper.markup_unsafe(readme.name, readme.data, project: project, markdown_engine: :redcarpet) if readme end cache_method :rendered_readme diff --git a/app/models/user.rb b/app/models/user.rb index 27a5d0278b7..1c5d39db118 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -496,7 +496,7 @@ class User < ActiveRecord::Base def disable_two_factor! transaction do - update_attributes( + update( otp_required_for_login: false, encrypted_otp_secret: nil, encrypted_otp_secret_iv: nil, @@ -1053,7 +1053,7 @@ class User < ActiveRecord::Base return @global_notification_setting if defined?(@global_notification_setting) @global_notification_setting = notification_settings.find_or_initialize_by(source: nil) - @global_notification_setting.update_attributes(level: NotificationSetting.levels[DEFAULT_NOTIFICATION_LEVEL]) unless @global_notification_setting.persisted? + @global_notification_setting.update(level: NotificationSetting.levels[DEFAULT_NOTIFICATION_LEVEL]) unless @global_notification_setting.persisted? @global_notification_setting end @@ -1333,8 +1333,8 @@ class User < ActiveRecord::Base end end - def self.unique_internal(scope, username, email_pattern, &b) - scope.first || create_unique_internal(scope, username, email_pattern, &b) + def self.unique_internal(scope, username, email_pattern, &block) + scope.first || create_unique_internal(scope, username, email_pattern, &block) end def self.create_unique_internal(scope, username, email_pattern, &creation_block) diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index cde79b95062..4b49edb01a5 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -1,3 +1,4 @@ +# rubocop:disable Rails/ActiveRecordAliases class WikiPage PageChangedError = Class.new(StandardError) PageRenameError = Class.new(StandardError) diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 520710b757d..ded9fe30eff 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -72,6 +72,19 @@ class GroupPolicy < BasePolicy enable :change_visibility_level end + rule { can?(:read_nested_project_resources) }.policy do + enable :read_group_activity + enable :read_group_issues + enable :read_group_boards + enable :read_group_labels + enable :read_group_milestones + enable :read_group_merge_requests + end + + rule { can?(:read_cross_project) & can?(:read_group) }.policy do + enable :read_nested_project_resources + end + rule { owner & nested_groups_supported }.enable :create_subgroup rule { public_group | logged_in_viewable }.enable :view_globally diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index ce0c31b5806..0e1f94a9f61 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -62,6 +62,8 @@ class NoteEntity < API::Entities::Note expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? } + expose :cached_markdown_version + private def current_user diff --git a/app/services/badges/update_service.rb b/app/services/badges/update_service.rb index 7ca84b5df31..495a4a2c99d 100644 --- a/app/services/badges/update_service.rb +++ b/app/services/badges/update_service.rb @@ -3,7 +3,7 @@ module Badges # returns the updated badge def execute(badge) if params.present? - badge.update_attributes(params) + badge.update(params) end badge diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb index b9d0173a2d0..1ce6ab36cbf 100644 --- a/app/services/commits/change_service.rb +++ b/app/services/commits/change_service.rb @@ -13,8 +13,6 @@ module Commits # rubocop:disable GitlabSecurity/PublicSend message = @commit.public_send(:"#{action}_message", current_user) - - # rubocop:disable GitlabSecurity/PublicSend repository.public_send( action, current_user, diff --git a/app/services/import_export_clean_up_service.rb b/app/services/import_export_clean_up_service.rb index 74088b970c9..3702c3742ef 100644 --- a/app/services/import_export_clean_up_service.rb +++ b/app/services/import_export_clean_up_service.rb @@ -10,7 +10,9 @@ class ImportExportCleanUpService def execute Gitlab::Metrics.measure(:import_export_clean_up) do - next unless File.directory?(path) + clean_up_export_object_files + + break unless File.directory?(path) clean_up_export_files end @@ -21,4 +23,11 @@ class ImportExportCleanUpService def clean_up_export_files Gitlab::Popen.popen(%W(find #{path} -not -path #{path} -mmin +#{mmin} -delete)) end + + def clean_up_export_object_files + ImportExportUpload.where('updated_at < ?', mmin.minutes.ago).each do |upload| + upload.remove_export_file! + upload.save! + end + end end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 683f64e82ad..5e06e0c61cf 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -130,7 +130,7 @@ class IssuableBaseService < BaseService def create_issuable(issuable, attributes, label_ids:) issuable.with_transaction_returning_status do if issuable.save - issuable.update_attributes(label_ids: label_ids) + issuable.update(label_ids: label_ids) end end end diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index 48b3d59f7bd..cb19cf01dd7 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -6,7 +6,7 @@ module Members old_access_level = member.human_access - if member.update_attributes(params) + if member.update(params) after_execute(action: permission, old_access_level: old_access_level, member: member) end diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index 5b4bc86b9ba..c741e913860 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -26,7 +26,7 @@ module MergeRequests Gitlab::GitLogger.info("#{log_prefix} rebased to #{rebase_sha}") - merge_request.update_attributes(rebase_commit_sha: rebase_sha) + merge_request.update(rebase_commit_sha: rebase_sha) Gitlab::GitLogger.info("#{log_prefix} rebase SHA saved: #{rebase_sha}") diff --git a/app/services/milestones/update_service.rb b/app/services/milestones/update_service.rb index 31b441ed476..74edbf9b41d 100644 --- a/app/services/milestones/update_service.rb +++ b/app/services/milestones/update_service.rb @@ -11,7 +11,7 @@ module Milestones end if params.present? - milestone.update_attributes(params.except(:state_event)) + milestone.update(params.except(:state_event)) end milestone diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 75fd08ea0a9..e16ef398184 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -5,7 +5,7 @@ module Notes old_mentioned_users = note.mentioned_users.to_a - note.update_attributes(params.merge(updated_by: current_user)) + note.update(params.merge(updated_by: current_user)) note.create_new_cross_references!(current_user) if note.previous_changes.include?('note') diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 4fa38665abc..d9834fd0ccc 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -10,16 +10,16 @@ module NotificationRecipientService NotificationRecipient.new(user, *args).notifiable? end - def self.build_recipients(*a) - Builder::Default.new(*a).notification_recipients + def self.build_recipients(*args) + Builder::Default.new(*args).notification_recipients end - def self.build_new_note_recipients(*a) - Builder::NewNote.new(*a).notification_recipients + def self.build_new_note_recipients(*args) + Builder::NewNote.new(*args).notification_recipients end - def self.build_merge_request_unmergeable_recipients(*a) - Builder::MergeRequestUnmergeable.new(*a).notification_recipients + def self.build_merge_request_unmergeable_recipients(*args) + Builder::MergeRequestUnmergeable.new(*args).notification_recipients end module Builder @@ -44,7 +44,6 @@ module NotificationRecipientService raise 'abstract' end - # rubocop:disable Rails/Delegate def project target.project end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 636cfbf5b45..8c6221af788 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -135,6 +135,8 @@ class NotificationService # * watchers of the mr's labels # * users with custom level checked with "new merge request" # + # In EE, approvers of the merge request are also included + # def new_merge_request(merge_request, current_user) new_resource_email(merge_request, :new_merge_request_email) end @@ -256,6 +258,10 @@ class NotificationService # ignore gitlab service messages return true if note.cross_reference? && note.system? + send_new_note_notifications(note) + end + + def send_new_note_notifications(note) notify_method = "note_#{note.to_ability_name}_email".to_sym recipients = NotificationRecipientService.build_new_note_recipients(note) diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index 4ee2c1796bd..6da4d9523cf 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -6,7 +6,8 @@ class PreviewMarkdownService < BaseService success( text: text, users: users, - commands: commands.join(' ') + commands: commands.join(' '), + markdown_engine: markdown_engine ) end @@ -42,4 +43,8 @@ class PreviewMarkdownService < BaseService def commands_target_id params[:quick_actions_target_id] end + + def markdown_engine + CacheMarkdownField::MarkdownEngine.from_version(params[:markdown_version].to_i) + end end diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index aa60661f7f2..9d0eaaf3152 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -20,24 +20,28 @@ module Projects MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title]) end - def labels(target = nil) - labels = LabelsFinder.new(current_user, project_id: project.id, include_ancestor_groups: true) - .execute.select([:color, :title]) - - return labels unless target&.respond_to?(:labels) - - issuable_label_titles = target.labels.pluck(:title) - - if issuable_label_titles - labels = labels.as_json(only: [:title, :color]) - - issuable_label_titles.each do |issuable_label_title| - found_label = labels.find { |label| label['title'] == issuable_label_title } - found_label[:set] = true if found_label + def labels_as_hash(target = nil) + available_labels = LabelsFinder.new( + current_user, + project_id: project.id, + include_ancestor_groups: true + ).execute + + label_hashes = available_labels.as_json(only: [:title, :color]) + + if target&.respond_to?(:labels) + already_set_labels = available_labels & target.labels + if already_set_labels.present? + titles = already_set_labels.map(&:title) + label_hashes.each do |hash| + if titles.include?(hash['title']) + hash[:set] = true + end + end end end - labels + label_hashes end def commands(noteable, type) diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 02769e72229..87173cc79ec 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -124,7 +124,7 @@ module Projects # It's possible that the project was destroyed, but some after_commit # hook failed and caused us to end up here. A destroyed model will be a frozen hash, # which cannot be altered. - project.update_attributes(delete_error: message, pending_delete: false) unless project.destroyed? + project.update(delete_error: message, pending_delete: false) unless project.destroyed? log_error("Deletion failed on #{project.full_path} with the following message: #{message}") end diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 348eb0bf8d8..a8aafa9fb4f 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -37,7 +37,7 @@ module Projects return new_project unless new_project.persisted? builds_access_level = @project.project_feature.builds_access_level - new_project.project_feature.update_attributes(builds_access_level: builds_access_level) + new_project.project_feature.update(builds_access_level: builds_access_level) link_fork_network(new_project) diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb index 6ea43561d61..618c30b971f 100644 --- a/app/services/projects/lfs_pointers/lfs_download_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_service.rb @@ -22,7 +22,7 @@ module Projects private def download_and_save_file(file, sanitized_uri) - IO.copy_stream(open(sanitized_uri.sanitized_url, headers(sanitized_uri)), file) + IO.copy_stream(open(sanitized_uri.sanitized_url, headers(sanitized_uri)), file) # rubocop:disable Security/Open end def headers(sanitized_uri) diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index d8250cd8102..f4fbaacc08b 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -22,7 +22,7 @@ module Projects # If the block added errors, don't try to save the project return validation_failed! if project.errors.any? - if project.update_attributes(params.except(:default_branch)) + if project.update(params.except(:default_branch)) if project.previous_changes.include?('path') project.rename_repo else diff --git a/app/services/update_release_service.rb b/app/services/update_release_service.rb index b7c36651968..dc696e9c440 100644 --- a/app/services/update_release_service.rb +++ b/app/services/update_release_service.rb @@ -7,7 +7,7 @@ class UpdateReleaseService < BaseService release = project.releases.find_by(tag: tag_name) if release - release.update_attributes(description: release_description) + release.update(description: release_description) success(release) else diff --git a/app/uploaders/import_export_uploader.rb b/app/uploaders/import_export_uploader.rb new file mode 100644 index 00000000000..213ac5c8011 --- /dev/null +++ b/app/uploaders/import_export_uploader.rb @@ -0,0 +1,15 @@ +class ImportExportUploader < AttachmentUploader + EXTENSION_WHITELIST = %w[tar.gz].freeze + + def extension_whitelist + EXTENSION_WHITELIST + end + + def move_to_store + true + end + + def move_to_cache + false + end +end diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index f7094375023..5e7be5cd37b 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -40,7 +40,7 @@ = project.human_import_status_name - @repos.each do |repo| - %tr{ id: "repo_#{repo.id}" } + %tr{ id: "repo_#{repo.id}", data: { qa: { repo_path: repo.full_name } } } %td = provider_project_link(provider, repo.full_name) %td.import-target @@ -50,7 +50,7 @@ - if current_user.can_select_namespace? - selected = params[:namespace_id] || :current_user - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner.login, path: repo.owner.login) } : {} - = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 } + = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace qa-project-namespace-select', tabindex: 1 } - else = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true %span.input-group-prepend diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index a74ea246eaf..9ed05d6e3d0 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -17,11 +17,7 @@ = link_to _("Help"), help_path - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) %li.divider - %li - = link_to "https://about.gitlab.com/contributing", target: '_blank', class: 'text-nowrap' do - = _("Contribute to GitLab") - = sprite_icon('external-link', size: 16) - %li.divider + = render 'shared/user_dropdown_contributing_link' - if current_user_menu?(:sign_out) %li = link_to _("Sign out"), destroy_user_session_path, class: "sign-out-link" diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index d8e32651b36..3aa8eb18bf3 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -61,7 +61,9 @@ - if header_link?(:sign_in) %li.nav-item %div - = link_to "Sign in / Register", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in' + - sign_in_text = allow_signup? ? 'Sign in / Register' : 'Sign in' + = link_to sign_in_text, new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in' + %button.navbar-toggler.d-block.d-sm-none{ type: 'button' } %span.sr-only Toggle navigation diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index d35df706036..792291bde75 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -37,7 +37,7 @@ %li.dropdown-bold-header GitLab - if current_user.can_create_project? %li - = link_to 'New project', new_project_path + = link_to 'New project', new_project_path, class: 'qa-global-new-project-link' - if current_user.can_create_group? %li = link_to 'New group', new_group_path diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 7647e25e804..4029287fc0e 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -1,16 +1,19 @@ %ul.list-unstyled.navbar-sub-nav - if dashboard_nav_link?(:projects) = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do - %a{ href: "#", data: { toggle: "dropdown" } } + %button{ type: 'button', data: { toggle: "dropdown" } } Projects = sprite_icon('angle-down', css_class: 'caret-down') - .dropdown-menu.projects-dropdown-menu + .dropdown-menu.frequent-items-dropdown-menu = render "layouts/nav/projects_dropdown/show" - if dashboard_nav_link?(:groups) - = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "d-none d-sm-block" }) do - = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups qa-groups-link', title: 'Groups' do + = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown" }) do + %button{ type: 'button', data: { toggle: "dropdown" } } Groups + = sprite_icon('angle-down', css_class: 'caret-down') + .dropdown-menu.frequent-items-dropdown-menu + = render "layouts/nav/groups_dropdown/show" - if dashboard_nav_link?(:activity) = nav_link(path: 'dashboard#activity', html_options: { class: "d-none d-lg-block d-xl-block" }) do @@ -34,11 +37,6 @@ = sprite_icon('angle-down', css_class: 'caret-down') .dropdown-menu %ul - - if dashboard_nav_link?(:groups) - = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "d-block d-sm-none" }) do - = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do - Groups - - if dashboard_nav_link?(:activity) = nav_link(path: 'dashboard#activity') do = link_to activity_dashboard_path, title: 'Activity' do diff --git a/app/views/layouts/nav/groups_dropdown/_show.html.haml b/app/views/layouts/nav/groups_dropdown/_show.html.haml new file mode 100644 index 00000000000..3ce1fa6bcca --- /dev/null +++ b/app/views/layouts/nav/groups_dropdown/_show.html.haml @@ -0,0 +1,12 @@ +- group_meta = { id: @group.id, name: @group.name, namespace: @group.full_name, web_url: group_path(@group), avatar_url: @group.avatar_url } if @group&.persisted? +.frequent-items-dropdown-container + .frequent-items-dropdown-sidebar.qa-groups-dropdown-sidebar + %ul + = nav_link(path: 'dashboard/groups#index') do + = link_to dashboard_groups_path, class: 'qa-your-groups-link' do + = _('Your groups') + = nav_link(path: 'groups#explore') do + = link_to explore_groups_path do + = _('Explore groups') + .frequent-items-dropdown-content + #js-groups-dropdown{ data: { user_name: current_user.username, group: group_meta } } diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml index 5809d6f7fea..f2170f71532 100644 --- a/app/views/layouts/nav/projects_dropdown/_show.html.haml +++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml @@ -1,6 +1,6 @@ - project_meta = { id: @project.id, name: @project.name, namespace: @project.full_name, web_url: project_path(@project), avatar_url: @project.avatar_url } if @project&.persisted? -.projects-dropdown-container - .project-dropdown-sidebar.qa-projects-dropdown-sidebar +.frequent-items-dropdown-container + .frequent-items-dropdown-sidebar.qa-projects-dropdown-sidebar %ul = nav_link(path: 'dashboard/projects#index') do = link_to dashboard_projects_path, class: 'qa-your-projects-link' do @@ -11,5 +11,5 @@ = nav_link(path: 'projects#trending') do = link_to explore_root_path do = _('Explore projects') - .project-dropdown-content + .frequent-items-dropdown-content #js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } } diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index c14700794ce..43a2d53b84d 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -5,11 +5,18 @@ .form-group = f.label :key, class: 'label-light' %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_rsa.pub' and begins with 'ssh-rsa'. Don't use your private SSH key.") - = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: 'Typically starts with "ssh-rsa …"' + = f.text_area :key, class: "form-control js-add-ssh-key-validation-input", rows: 8, required: true, placeholder: s_('Profiles|Typically starts with "ssh-rsa …"') .form-group = f.label :title, class: 'label-light' - = f.text_field :title, class: "form-control", required: true, placeholder: 'e.g. My MacBook key' + = f.text_field :title, class: "form-control input-lg", required: true, placeholder: s_('Profiles|e.g. My MacBook key') %p.form-text.text-muted= _('Name your individual key via a title') + .js-add-ssh-key-validation-warning.hide + .bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' } + %strong= _('Oops, are you sure?') + %p= s_("Profiles|This doesn't look like a public SSH key, are you sure you want to add it?") + + %button.btn.btn-create.js-add-ssh-key-validation-confirm-submit= _("Yes, add it") + .prepend-top-default - = f.submit 'Add key', class: "btn btn-create" + = f.submit s_('Profiles|Add key'), class: "btn btn-create js-add-ssh-key-validation-original-submit" diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index f4d4888bd15..aa980da7e95 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -31,7 +31,7 @@ %li Any encrypted tokens %p Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page. - - if project.export_project_path + - if project.export_status == :finished = link_to 'Download export', download_export_project_path(project), rel: 'nofollow', download: '', method: :get, class: "btn btn-default" = link_to 'Generate new export', generate_new_export_project_path(project), diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml index 2c5ffd85372..1e4e9450ffa 100644 --- a/app/views/projects/issues/_form.html.haml +++ b/app/views/projects/issues/_form.html.haml @@ -1,2 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @issue], html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' } do |f| += form_for [@project.namespace.becomes(Namespace), @project, @issue], + html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' }, + data: { markdown_version: @issue.cached_markdown_version } do |f| = render 'shared/issuable/form', f: f, issuable: @issue diff --git a/app/views/projects/merge_requests/_form.html.haml b/app/views/projects/merge_requests/_form.html.haml index 179c1fcc684..5a59f956cb5 100644 --- a/app/views/projects/merge_requests/_form.html.haml +++ b/app/views/projects/merge_requests/_form.html.haml @@ -1,2 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| += form_for [@project.namespace.becomes(Namespace), @project, @merge_request], + html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' }, + data: { markdown_version: @merge_request.cached_markdown_version } do |f| = render 'shared/issuable/form', f: f, issuable: @merge_request diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 4cc59718715..ace094a671a 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -1,4 +1,6 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @milestone], html: {class: 'milestone-form common-note-form js-quick-submit js-requires-input'} do |f| += form_for [@project.namespace.becomes(Namespace), @project, @milestone], + html: {class: 'milestone-form common-note-form js-quick-submit js-requires-input'}, + data: { markdown_version: @milestone.cached_markdown_version } do |f| = form_errors(@milestone) .row .col-md-6 diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml index d6f758608a0..8093cc2c2d7 100644 --- a/app/views/projects/releases/edit.html.haml +++ b/app/views/projects/releases/edit.html.haml @@ -11,7 +11,9 @@ %strong= @tag.name - = form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name), html: { class: 'common-note-form release-form js-quick-submit' }) do |f| + = form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name), + html: { class: 'common-note-form release-form js-quick-submit' }, + data: { markdown_version: @release.cached_markdown_version }) do |f| = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…" = render 'shared/notes/hints' diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 26fe1de31fe..de692466fe5 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -1,7 +1,9 @@ - commit_message = @page.persisted? ? s_("WikiPageEdit|Update %{page_title}") : s_("WikiPageCreate|Create %{page_title}") - commit_message = commit_message % { page_title: @page.title } -= form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post, html: { class: 'wiki-form common-note-form prepend-top-default js-quick-submit' } do |f| += form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post, + html: { class: 'wiki-form common-note-form prepend-top-default js-quick-submit' }, + data: { markdown_version: CacheMarkdownField::CACHE_REDCARPET_VERSION } do |f| = form_errors(@page) - if @page.persisted? diff --git a/app/views/shared/_user_dropdown_contributing_link.html.haml b/app/views/shared/_user_dropdown_contributing_link.html.haml new file mode 100644 index 00000000000..333d6fa3489 --- /dev/null +++ b/app/views/shared/_user_dropdown_contributing_link.html.haml @@ -0,0 +1,5 @@ +%li + = link_to "https://about.gitlab.com/contributing", target: '_blank', class: 'text-nowrap' do + = _("Contribute to GitLab") + = sprite_icon('external-link', size: 16) +%li.divider diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml index 532712ee6d1..f3b56df0c96 100644 --- a/app/views/shared/hook_logs/_content.html.haml +++ b/app/views/shared/hook_logs/_content.html.haml @@ -30,7 +30,7 @@ %h5 Request body: %pre - :plain + :escaped #{JSON.pretty_generate(hook_log.request_data)} %h5 Response headers: %pre @@ -40,5 +40,5 @@ %h5 Response body: %pre - :plain + :escaped #{hook_log.response_body} diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index d4e8f30e458..f5464058bc0 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -52,7 +52,7 @@ .note-text.md = markdown_field(note, :note) = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago') - .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } + .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore, markdown_version: note.cached_markdown_version } } #{note.note} - if note_editable = render 'shared/notes/edit', note: note diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 88f0675f795..6be1fb485a4 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -4,7 +4,7 @@ - ci = false unless local_assigns[:ci] == true - skip_namespace = false unless local_assigns[:skip_namespace] == true - user = local_assigns[:user] -- access = user&.max_member_access_for_project(project.id) unless user.nil? +- access = max_project_member_access(project) - css_class = '' unless local_assigns[:css_class] - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project) - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 858adc8be37..5e5c050d5c3 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -2,7 +2,9 @@ = page_specific_javascript_tag('lib/ace.js') .snippet-form-holder - = form_for @snippet, url: url, html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" } do |f| + = form_for @snippet, url: url, + html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" }, + data: { markdown_version: @snippet.cached_markdown_version } do |f| = form_errors(@snippet) .form-group.row diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index b8b854853b7..d4be1ccfcfa 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -46,7 +46,6 @@ - mail_scheduler:mail_scheduler_issue_due - mail_scheduler:mail_scheduler_notification_service -- object_storage_upload - object_storage:object_storage_background_move - object_storage:object_storage_migrate_uploads diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb index 9169f21af2a..c6f89a17729 100644 --- a/app/workers/archive_trace_worker.rb +++ b/app/workers/archive_trace_worker.rb @@ -5,7 +5,7 @@ class ArchiveTraceWorker include PipelineBackgroundQueue def perform(job_id) - Ci::Build.find_by(id: job_id).try do |job| + Ci::Build.without_archived_trace.find_by(id: job_id).try do |job| job.trace.archive! end end diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb index 7016edde698..7d4e9660a4e 100644 --- a/app/workers/ci/archive_traces_cron_worker.rb +++ b/app/workers/ci/archive_traces_cron_worker.rb @@ -12,6 +12,7 @@ module Ci Ci::Build.finished.with_live_trace.find_each(batch_size: 100) do |build| begin build.trace.archive! + rescue ::Gitlab::Ci::Trace::AlreadyArchivedError rescue => e failed_archive_counter.increment Rails.logger.error "Failed to archive stale live trace. id: #{build.id} message: #{e.message}" diff --git a/app/workers/ci/build_trace_chunk_flush_worker.rb b/app/workers/ci/build_trace_chunk_flush_worker.rb index 6376c6d32cf..9dbf2e5e1ac 100644 --- a/app/workers/ci/build_trace_chunk_flush_worker.rb +++ b/app/workers/ci/build_trace_chunk_flush_worker.rb @@ -7,7 +7,7 @@ module Ci def perform(build_trace_chunk_id) ::Ci::BuildTraceChunk.find_by(id: build_trace_chunk_id).try do |build_trace_chunk| - build_trace_chunk.use_database! + build_trace_chunk.persist_data! end end end diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb index f9f0efb302a..12706613ac2 100644 --- a/app/workers/email_receiver_worker.rb +++ b/app/workers/email_receiver_worker.rb @@ -15,14 +15,14 @@ class EmailReceiverWorker private - def handle_failure(raw, e) - Rails.logger.warn("Email can not be processed: #{e}\n\n#{raw}") + def handle_failure(raw, error) + Rails.logger.warn("Email can not be processed: #{error}\n\n#{raw}") return unless raw.present? can_retry = false reason = - case e + case error when Gitlab::Email::UnknownIncomingEmail "We couldn't figure out what the email is for. Please create your issue or comment through the web interface." when Gitlab::Email::SentNotificationNotFoundError @@ -42,7 +42,7 @@ class EmailReceiverWorker "The thread you are replying to no longer exists, perhaps it was deleted? If you believe this is in error, contact a staff member." when Gitlab::Email::InvalidRecordError can_retry = true - e.message + error.message end if reason diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb index a3ecfa8e711..01d03ec7888 100644 --- a/app/workers/object_storage/migrate_uploads_worker.rb +++ b/app/workers/object_storage/migrate_uploads_worker.rb @@ -1,6 +1,4 @@ # frozen_string_literal: true -# rubocop:disable Metrics/LineLength -# rubocop:disable Style/Documentation module ObjectStorage class MigrateUploadsWorker diff --git a/app/workers/object_storage_upload_worker.rb b/app/workers/object_storage_upload_worker.rb deleted file mode 100644 index f17980a83d8..00000000000 --- a/app/workers/object_storage_upload_worker.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -# @Deprecated - remove once the `object_storage_upload` queue is empty -# The queue has been renamed `object_storage:object_storage_background_upload` -# -class ObjectStorageUploadWorker - include ApplicationWorker - - sidekiq_options retry: 5 - - def perform(uploader_class_name, subject_class_name, file_field, subject_id) - uploader_class = uploader_class_name.constantize - subject_class = subject_class_name.constantize - - return unless uploader_class < ObjectStorage::Concern - return unless uploader_class.object_store_enabled? - return unless uploader_class.background_upload_enabled? - - subject = subject_class.find(subject_id) - uploader = subject.public_send(file_field) # rubocop:disable GitlabSecurity/PublicSend - uploader.migrate!(ObjectStorage::Store::REMOTE) - end -end diff --git a/app/workers/repository_check/batch_worker.rb b/app/workers/repository_check/batch_worker.rb index 051382a08a9..07559ea479b 100644 --- a/app/workers/repository_check/batch_worker.rb +++ b/app/workers/repository_check/batch_worker.rb @@ -4,9 +4,11 @@ module RepositoryCheck class BatchWorker include ApplicationWorker include RepositoryCheckQueue + include ExclusiveLeaseGuard RUN_TIME = 3600 BATCH_SIZE = 10_000 + LEASE_TIMEOUT = 1.hour attr_reader :shard_name @@ -16,6 +18,20 @@ module RepositoryCheck return unless Gitlab::CurrentSettings.repository_checks_enabled return unless Gitlab::ShardHealthCache.healthy_shard?(shard_name) + try_obtain_lease do + perform_repository_checks + end + end + + def lease_timeout + LEASE_TIMEOUT + end + + def lease_key + "repository_check_batch_worker:#{shard_name}" + end + + def perform_repository_checks start = Time.now # This loop will break after a little more than one hour ('a little @@ -26,7 +42,7 @@ module RepositoryCheck project_ids.each do |project_id| break if Time.now - start >= RUN_TIME - next unless try_obtain_lease(project_id) + next unless try_obtain_lease_for_project(project_id) SingleRepositoryWorker.new.perform(project_id) end @@ -60,7 +76,7 @@ module RepositoryCheck Project.where(repository_storage: shard_name) end - def try_obtain_lease(id) + def try_obtain_lease_for_project(id) # Use a 24-hour timeout because on servers/projects where 'git fsck' is # super slow we definitely do not want to run it twice in parallel. Gitlab::ExclusiveLease.new( diff --git a/app/workers/repository_check/dispatch_worker.rb b/app/workers/repository_check/dispatch_worker.rb index 891a273afd7..96634f09a15 100644 --- a/app/workers/repository_check/dispatch_worker.rb +++ b/app/workers/repository_check/dispatch_worker.rb @@ -3,13 +3,22 @@ module RepositoryCheck include ApplicationWorker include CronjobQueue include ::EachShardWorker + include ExclusiveLeaseGuard + + LEASE_TIMEOUT = 1.hour def perform return unless Gitlab::CurrentSettings.repository_checks_enabled - each_eligible_shard do |shard_name| - RepositoryCheck::BatchWorker.perform_async(shard_name) + try_obtain_lease do + each_eligible_shard do |shard_name| + RepositoryCheck::BatchWorker.perform_async(shard_name) + end end end + + def lease_timeout + LEASE_TIMEOUT + end end end |