diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2019-11-15 12:06:12 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2019-11-15 12:06:12 +0000 |
commit | 3fc9a8e6957ddf75576dc63069c4c0249514499f (patch) | |
tree | 003e30463853843d6fb736a9396c7eb53a3dfc9a | |
parent | e24153b0cb080b1b25076f8fd358b4273848f2e2 (diff) | |
download | gitlab-ce-3fc9a8e6957ddf75576dc63069c4c0249514499f.tar.gz |
Add latest changes from gitlab-org/gitlab@master
97 files changed, 1431 insertions, 544 deletions
diff --git a/.gitignore b/.gitignore index 65befc20963..6bec6d2596f 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,4 @@ jsdoc/ **/tmp/rubocop_cache/** .overcommit.yml .projections.json +/qa/.rakeTasks @@ -159,6 +159,7 @@ gem 'icalendar' # Diffs gem 'diffy', '~> 3.1.0' +gem 'diff_match_patch', '~> 0.1.0' # Application server gem 'rack', '~> 2.0.7' diff --git a/Gemfile.lock b/Gemfile.lock index 7582eb28e49..5951eb4bf71 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -224,6 +224,7 @@ GEM railties rotp (~> 2.0) diff-lcs (1.3) + diff_match_patch (0.1.0) diffy (3.1.0) discordrb-webhooks-blackst0ne (3.3.0) rest-client (~> 2.0) @@ -1133,6 +1134,7 @@ DEPENDENCIES device_detector devise (~> 4.6) devise-two-factor (~> 3.0.0) + diff_match_patch (~> 0.1.0) diffy (~> 3.1.0) discordrb-webhooks-blackst0ne (~> 3.3) doorkeeper (~> 4.3) diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js index 900f0cf5bb8..d172aa8a444 100644 --- a/app/assets/javascripts/helpers/monitor_helper.js +++ b/app/assets/javascripts/helpers/monitor_helper.js @@ -1,11 +1,9 @@ -/* eslint-disable import/prefer-default-export */ -import _ from 'underscore'; - /** * @param {Array} queryResults - Array of Result objects * @param {Object} defaultConfig - Default chart config values (e.g. lineStyle, name) * @returns {Array} The formatted values */ +// eslint-disable-next-line import/prefer-default-export export const makeDataSeries = (queryResults, defaultConfig) => queryResults .map(result => { @@ -19,10 +17,13 @@ export const makeDataSeries = (queryResults, defaultConfig) => if (name) { series.name = `${defaultConfig.name}: ${name}`; } else { - const template = _.template(defaultConfig.name, { - interpolate: /\{\{(.+?)\}\}/g, + series.name = defaultConfig.name; + Object.keys(result.metric).forEach(templateVar => { + const value = result.metric[templateVar]; + const regex = new RegExp(`{{\\s*${templateVar}\\s*}}`, 'g'); + + series.name = series.name.replace(regex, value); }); - series.name = template(result.metric); } return { ...defaultConfig, ...series }; diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index a9e086fade8..9136a47d542 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,4 +1,4 @@ -/* eslint-disable no-var, one-var, consistent-return */ +/* eslint-disable consistent-return */ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; @@ -91,18 +91,17 @@ export default class Issue { 'click', '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', e => { - var $button, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); - $button = $(e.currentTarget); - shouldSubmit = $button.hasClass('btn-comment'); + const $button = $(e.currentTarget); + const shouldSubmit = $button.hasClass('btn-comment'); if (shouldSubmit) { Issue.submitNoteForm($button.closest('form')); } this.disableCloseReopenButton($button); - url = $button.attr('href'); + const url = $button.attr('href'); return axios .put(url) .then(({ data }) => { @@ -139,16 +138,14 @@ export default class Issue { } static submitNoteForm(form) { - var noteText; - noteText = form.find('textarea.js-note-text').val(); + const noteText = form.find('textarea.js-note-text').val(); if (noteText && noteText.trim().length > 0) { return form.submit(); } } static initRelatedBranches() { - var $container; - $container = $('#related-branches'); + const $container = $('#related-branches'); return axios .get($container.data('url')) .then(({ data }) => { diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 72de3b5d726..6abf723be9a 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -1,4 +1,4 @@ -/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, one-var, no-new, consistent-return, no-shadow, no-param-reassign, vars-on-top, no-lonely-if, no-else-return, dot-notation, no-empty */ +/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-else-return, dot-notation, no-empty */ /* global Issuable */ /* global ListLabel */ @@ -15,63 +15,39 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; export default class LabelsSelect { constructor(els, options = {}) { - var _this, $els; - _this = this; + const _this = this; - $els = $(els); + let $els = $(els); if (!els) { $els = $('.js-label-select'); } $els.each((i, dropdown) => { - var $block, - $dropdown, - $form, - $loading, - $selectbox, - $sidebarCollapsedValue, - $value, - $dropdownMenu, - abilityName, - defaultLabel, - issueUpdateURL, - labelUrl, - namespacePath, - projectPath, - saveLabelData, - selectedLabel, - showAny, - showNo, - $sidebarLabelTooltip, - initialSelected, - fieldName, - showMenuAbove, - $dropdownContainer; - $dropdown = $(dropdown); - $dropdownContainer = $dropdown.closest('.labels-filter'); - namespacePath = $dropdown.data('namespacePath'); - projectPath = $dropdown.data('projectPath'); - issueUpdateURL = $dropdown.data('issueUpdate'); - selectedLabel = $dropdown.data('selected'); + const $dropdown = $(dropdown); + const $dropdownContainer = $dropdown.closest('.labels-filter'); + const namespacePath = $dropdown.data('namespacePath'); + const projectPath = $dropdown.data('projectPath'); + const issueUpdateURL = $dropdown.data('issueUpdate'); + let selectedLabel = $dropdown.data('selected'); if (selectedLabel != null && !$dropdown.hasClass('js-multiselect')) { selectedLabel = selectedLabel.split(','); } - showNo = $dropdown.data('showNo'); - showAny = $dropdown.data('showAny'); - showMenuAbove = $dropdown.data('showMenuAbove'); - defaultLabel = $dropdown.data('defaultLabel') || __('Label'); - abilityName = $dropdown.data('abilityName'); - $selectbox = $dropdown.closest('.selectbox'); - $block = $selectbox.closest('.block'); - $form = $dropdown.closest('form, .js-issuable-update'); - $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); - $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); - $value = $block.find('.value'); - $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); - $loading = $block.find('.block-loading').fadeOut(); - fieldName = $dropdown.data('fieldName'); - initialSelected = $selectbox + const showNo = $dropdown.data('showNo'); + const showAny = $dropdown.data('showAny'); + const showMenuAbove = $dropdown.data('showMenuAbove'); + const defaultLabel = $dropdown.data('defaultLabel') || __('Label'); + const abilityName = $dropdown.data('abilityName'); + const $selectbox = $dropdown.closest('.selectbox'); + const $block = $selectbox.closest('.block'); + const $form = $dropdown.closest('form, .js-issuable-update'); + const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); + const $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); + const $value = $block.find('.value'); + const $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); + const $loading = $block.find('.block-loading').fadeOut(); + const fieldName = $dropdown.data('fieldName'); + let initialSelected = $selectbox .find(`input[name="${$dropdown.data('fieldName')}"]`) .map(function() { return this.value; @@ -90,9 +66,8 @@ export default class LabelsSelect { ); } - saveLabelData = function() { - var data, selected; - selected = $dropdown + const saveLabelData = function() { + const selected = $dropdown .closest('.selectbox') .find(`input[name='${fieldName}']`) .map(function() { @@ -103,7 +78,7 @@ export default class LabelsSelect { if (_.isEqual(initialSelected, selected)) return; initialSelected = selected; - data = {}; + const data = {}; data[abilityName] = {}; data[abilityName].label_ids = selected; if (!selected.length) { @@ -114,12 +89,13 @@ export default class LabelsSelect { axios .put(issueUpdateURL, data) .then(({ data }) => { - var labelCount, template, labelTooltipTitle, labelTitles; + let labelTooltipTitle; + let template; $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); data.issueUpdateURL = issueUpdateURL; - labelCount = 0; + let labelCount = 0; if (data.labels.length && issueUpdateURL) { template = LabelsSelect.getLabelTemplate({ labels: _.sortBy(data.labels, 'title'), @@ -174,7 +150,7 @@ export default class LabelsSelect { $sidebarCollapsedValue.text(labelCount); if (data.labels.length) { - labelTitles = data.labels.map(label => label.title); + let labelTitles = data.labels.map(label => label.title); if (labelTitles.length > 5) { labelTitles = labelTitles.slice(0, 5); @@ -199,13 +175,13 @@ export default class LabelsSelect { $dropdown.glDropdown({ showMenuAbove, data(term, callback) { - labelUrl = $dropdown.attr('data-labels'); + const labelUrl = $dropdown.attr('data-labels'); axios .get(labelUrl) .then(res => { let { data } = res; if ($dropdown.hasClass('js-extra-options')) { - var extraData = []; + const extraData = []; if (showNo) { extraData.unshift({ id: 0, @@ -232,22 +208,14 @@ export default class LabelsSelect { .catch(() => flash(__('Error fetching labels.'))); }, renderRow(label) { - var linkEl, - listItemEl, - colorEl, - indeterminate, - removesAll, - selectedClass, - i, - marked, - dropdownValue; - - selectedClass = []; - removesAll = label.id <= 0 || label.id == null; + let colorEl; + + const selectedClass = []; + const removesAll = label.id <= 0 || label.id == null; if ($dropdown.hasClass('js-filter-bulk-update')) { - indeterminate = $dropdown.data('indeterminate') || []; - marked = $dropdown.data('marked') || []; + const indeterminate = $dropdown.data('indeterminate') || []; + const marked = $dropdown.data('marked') || []; if (indeterminate.indexOf(label.id) !== -1) { selectedClass.push('is-indeterminate'); @@ -255,7 +223,7 @@ export default class LabelsSelect { if (marked.indexOf(label.id) !== -1) { // Remove is-indeterminate class if the item will be marked as active - i = selectedClass.indexOf('is-indeterminate'); + const i = selectedClass.indexOf('is-indeterminate'); if (i !== -1) { selectedClass.splice(i, 1); } @@ -263,7 +231,7 @@ export default class LabelsSelect { } } else { if (this.id(label)) { - dropdownValue = this.id(label) + const dropdownValue = this.id(label) .toString() .replace(/'/g, "\\'"); @@ -287,7 +255,7 @@ export default class LabelsSelect { colorEl = ''; } - linkEl = document.createElement('a'); + const linkEl = document.createElement('a'); linkEl.href = '#'; // We need to identify which items are actually labels @@ -300,7 +268,7 @@ export default class LabelsSelect { linkEl.className = selectedClass.join(' '); linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`; - listItemEl = document.createElement('li'); + const listItemEl = document.createElement('li'); listItemEl.appendChild(linkEl); return listItemEl; @@ -312,12 +280,12 @@ export default class LabelsSelect { filterable: true, selected: $dropdown.data('selected') || [], toggleLabel(selected, el) { - var $dropdownParent = $dropdown.parent(); - var $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); - var isSelected = el !== null ? el.hasClass('is-active') : false; + const $dropdownParent = $dropdown.parent(); + const $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); + const isSelected = el !== null ? el.hasClass('is-active') : false; - var title = selected ? selected.title : null; - var selectedLabels = this.selected; + const title = selected ? selected.title : null; + const selectedLabels = this.selected; if ($dropdownInputField.length && $dropdownInputField.val().length) { $dropdownParent.find('.dropdown-input-clear').trigger('click'); @@ -329,7 +297,7 @@ export default class LabelsSelect { } else if (isSelected) { this.selected.push(title); } else if (!isSelected && title) { - var index = this.selected.indexOf(title); + const index = this.selected.indexOf(title); this.selected.splice(index, 1); } @@ -359,10 +327,9 @@ export default class LabelsSelect { } }, hidden() { - var isIssueIndex, isMRIndex, page; - page = $('body').attr('data-page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = page === 'projects:merge_requests:index'; + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === 'projects:merge_requests:index'; $selectbox.hide(); // display:block overrides the hide-collapse rule $value.removeAttr('style'); @@ -393,14 +360,13 @@ export default class LabelsSelect { const { $el, e, isMarking } = clickEvent; const label = clickEvent.selectedObj; - var isIssueIndex, isMRIndex, page, boardsModel; - var fadeOutLoader = () => { + const fadeOutLoader = () => { $loading.fadeOut(); }; - page = $('body').attr('data-page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = page === 'projects:merge_requests:index'; + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === 'projects:merge_requests:index'; if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) { $dropdown @@ -419,6 +385,7 @@ export default class LabelsSelect { return; } + let boardsModel; if ($dropdown.closest('.add-issues-modal').length) { boardsModel = ModalStore.store.filter; } @@ -450,7 +417,7 @@ export default class LabelsSelect { }), ); } else { - var { labels } = boardsStore.detail.issue; + let { labels } = boardsStore.detail.issue; labels = labels.filter(selectedLabel => selectedLabel.id !== label.id); boardsStore.detail.issue.labels = labels; } @@ -578,16 +545,14 @@ export default class LabelsSelect { } // eslint-disable-next-line class-methods-use-this setDropdownData($dropdown, isMarking, value) { - var i, markedIds, unmarkedIds, indeterminateIds; - - markedIds = $dropdown.data('marked') || []; - unmarkedIds = $dropdown.data('unmarked') || []; - indeterminateIds = $dropdown.data('indeterminate') || []; + const markedIds = $dropdown.data('marked') || []; + const unmarkedIds = $dropdown.data('unmarked') || []; + const indeterminateIds = $dropdown.data('indeterminate') || []; if (isMarking) { markedIds.push(value); - i = indeterminateIds.indexOf(value); + let i = indeterminateIds.indexOf(value); if (i > -1) { indeterminateIds.splice(i, 1); } @@ -598,7 +563,7 @@ export default class LabelsSelect { } } else { // If marked item (not common) is unmarked - i = markedIds.indexOf(value); + const i = markedIds.indexOf(value); if (i > -1) { markedIds.splice(i, 1); } diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index a670becf91a..6a8e3cc82f5 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -84,7 +84,10 @@ export const fetchDashboard = ({ state, dispatch }, params) => { return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) .then(resp => resp.data) .then(response => { - dispatch('receiveMetricsDashboardSuccess', { response, params }); + dispatch('receiveMetricsDashboardSuccess', { + response, + params, + }); }) .catch(error => { dispatch('receiveMetricsDashboardFailure', error); diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 26d4c56ca78..696af5aed75 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -94,7 +94,7 @@ export default { state.emptyState = 'noData'; }, [types.SET_ALL_DASHBOARDS](state, dashboards) { - state.allDashboards = dashboards; + state.allDashboards = dashboards || []; }, [types.SET_SHOW_ERROR_BANNER](state, enabled) { state.showErrorBanner = enabled; diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 3158e086f6c..e4f09492d9c 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -101,6 +101,7 @@ export default { <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" /> </a> </template> + <slot name="extra-controls"></slot> <i class="fa fa-spinner fa-spin editing-spinner" :aria-label="__('Comment is being updated')" diff --git a/app/assets/javascripts/notes/mixins/description_version_history.js b/app/assets/javascripts/notes/mixins/description_version_history.js new file mode 100644 index 00000000000..12d80f3faa2 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/description_version_history.js @@ -0,0 +1,12 @@ +// Placeholder for GitLab FOSS +// Actual implementation: ee/app/assets/javascripts/notes/mixins/description_version_history.js +export default { + computed: { + canSeeDescriptionVersion() {}, + shouldShowDescriptionVersion() {}, + descriptionVersionToggleIcon() {}, + }, + methods: { + toggleDescriptionVersion() {}, + }, +}; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 004035ea1d4..82c291379ec 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -12,6 +12,7 @@ import service from '../services/notes_service'; import loadAwardsHandler from '../../awards_handler'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; +import { mergeUrlParams } from '../../lib/utils/url_utility'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; import { __ } from '~/locale'; import Api from '~/api'; @@ -475,5 +476,20 @@ export const convertToDiscussion = ({ commit }, noteId) => export const removeConvertedDiscussion = ({ commit }, noteId) => commit(types.REMOVE_CONVERTED_DISCUSSION, noteId); +export const fetchDescriptionVersion = (_, { endpoint, startingVersion }) => { + let requestUrl = endpoint; + + if (startingVersion) { + requestUrl = mergeUrlParams({ start_version_id: startingVersion }, requestUrl); + } + + return axios + .get(requestUrl) + .then(res => res.data) + .catch(() => { + Flash(__('Something went wrong while fetching description changes. Please try again.')); + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/collapse_utils.js b/app/assets/javascripts/notes/stores/collapse_utils.js index bee6d4f0329..3cdcc7a05b8 100644 --- a/app/assets/javascripts/notes/stores/collapse_utils.js +++ b/app/assets/javascripts/notes/stores/collapse_utils.js @@ -1,34 +1,9 @@ -import { n__, s__, sprintf } from '~/locale'; import { DESCRIPTION_TYPE } from '../constants'; /** - * Changes the description from a note, returns 'changed the description n number of times' - */ -export const changeDescriptionNote = (note, descriptionChangedTimes, timeDifferenceMinutes) => { - const descriptionNote = Object.assign({}, note); - - descriptionNote.note_html = sprintf( - s__(`MergeRequest| - %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}`), - { - paragraphStart: '<p dir="auto">', - paragraphEnd: '</p>', - descriptionChangedTimes, - timeDifferenceMinutes: n__('within %d minute ', 'within %d minutes ', timeDifferenceMinutes), - }, - false, - ); - - descriptionNote.times_updated = descriptionChangedTimes; - - return descriptionNote; -}; - -/** * Checks the time difference between two notes from their 'created_at' dates * returns an integer */ - export const getTimeDifferenceMinutes = (noteBeggining, noteEnd) => { const descriptionNoteBegin = new Date(noteBeggining.created_at); const descriptionNoteEnd = new Date(noteEnd.created_at); @@ -57,7 +32,6 @@ export const isDescriptionSystemNote = note => note.system && note.note === DESC export const collapseSystemNotes = notes => { let lastDescriptionSystemNote = null; let lastDescriptionSystemNoteIndex = -1; - let descriptionChangedTimes = 1; return notes.slice(0).reduce((acc, currentNote) => { const note = currentNote.notes[0]; @@ -70,32 +44,24 @@ export const collapseSystemNotes = notes => { } else if (lastDescriptionSystemNote) { const timeDifferenceMinutes = getTimeDifferenceMinutes(lastDescriptionSystemNote, note); - // are they less than 10 minutes apart? - if (timeDifferenceMinutes > 10) { - // reset counter - descriptionChangedTimes = 1; + // are they less than 10 minutes apart from the same user? + if (timeDifferenceMinutes > 10 || note.author.id !== lastDescriptionSystemNote.author.id) { // update the previous system note lastDescriptionSystemNote = note; lastDescriptionSystemNoteIndex = acc.length; } else { - // increase counter - descriptionChangedTimes += 1; + // set the first version to fetch grouped system note versions + note.start_description_version_id = lastDescriptionSystemNote.description_version_id; // delete the previous one acc.splice(lastDescriptionSystemNoteIndex, 1); - // replace the text of the current system note with the collapsed note. - currentNote.notes.splice( - 0, - 1, - changeDescriptionNote(note, descriptionChangedTimes, timeDifferenceMinutes), - ); - // update the previous system note index lastDescriptionSystemNoteIndex = acc.length; } } } + acc.push(currentNote); return acc; }, []); diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index 95f8270b5d0..5a6f9370564 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -8,12 +8,13 @@ import { GlModalDirective, GlEmptyState, } from '@gitlab/ui'; -import createFlash from '../../flash'; -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import Icon from '../../vue_shared/components/icon.vue'; +import createFlash from '~/flash'; +import Tracking from '~/tracking'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import TableRegistry from './table_registry.vue'; -import { errorMessages, errorMessagesTypes } from '../constants'; -import { __ } from '../../locale'; +import { DELETE_REPO_ERROR_MESSAGE } from '../constants'; +import { __ } from '~/locale'; export default { name: 'CollapsibeContainerRegisty', @@ -30,6 +31,7 @@ export default { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective, }, + mixins: [Tracking.mixin({})], props: { repo: { type: Object, @@ -40,6 +42,10 @@ export default { return { isOpen: false, modalId: `confirm-repo-deletion-modal-${this.repo.id}`, + tracking: { + category: document.body.dataset.page, + label: 'registry_repository_delete', + }, }; }, computed: { @@ -61,15 +67,13 @@ export default { } }, handleDeleteRepository() { + this.track('confirm_delete', {}); return this.deleteItem(this.repo) .then(() => { createFlash(__('This container registry has been scheduled for deletion.'), 'notice'); this.fetchRepos(); }) - .catch(() => this.showError(errorMessagesTypes.DELETE_REPO)); - }, - showError(message) { - createFlash(errorMessages[message]); + .catch(() => createFlash(DELETE_REPO_ERROR_MESSAGE)); }, }, }; @@ -97,10 +101,9 @@ export default { v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove repository')" :aria-label="s__('ContainerRegistry|Remove repository')" - data-track-event="click_button" - data-track-label="registry_repository_delete" class="js-remove-repo btn-inverted" variant="danger" + @click="track('click_button', {})" > <icon name="remove" /> </gl-button> @@ -124,7 +127,13 @@ export default { class="mx-auto my-0" /> </div> - <gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository"> + <gl-modal + ref="deleteModal" + :modal-id="modalId" + ok-variant="danger" + @ok="handleDeleteRepository" + @cancel="track('cancel_delete', {})" + > <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template> <p v-html=" diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index 8470fbc2b59..caa5fd4ff4e 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -1,20 +1,15 @@ <script> import { mapActions, mapGetters } from 'vuex'; -import { - GlButton, - GlFormCheckbox, - GlTooltipDirective, - GlModal, - GlModalDirective, -} from '@gitlab/ui'; -import { n__, s__, sprintf } from '../../locale'; -import createFlash from '../../flash'; -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue'; -import Icon from '../../vue_shared/components/icon.vue'; -import timeagoMixin from '../../vue_shared/mixins/timeago'; -import { errorMessages, errorMessagesTypes } from '../constants'; -import { numberToHumanSize } from '../../lib/utils/number_utils'; +import { GlButton, GlFormCheckbox, GlTooltipDirective, GlModal } from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { n__, s__, sprintf } from '~/locale'; +import createFlash from '~/flash'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { FETCH_REGISTRY_ERROR_MESSAGE, DELETE_REGISTRY_ERROR_MESSAGE } from '../constants'; export default { components: { @@ -27,7 +22,6 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - GlModal: GlModalDirective, }, mixins: [timeagoMixin], props: { @@ -65,12 +59,21 @@ export default { this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length, ); }, - }, - mounted() { - this.$refs.deleteModal.$refs.modal.$on('hide', this.removeModalEvents); + isMultiDelete() { + return this.itemsToBeDeleted.length > 1; + }, + tracking() { + return { + property: this.repo.name, + label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete', + }; + }, }, methods: { ...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']), + track(action) { + Tracking.event(document.body.dataset.page, action, this.tracking); + }, setModalDescription(itemIndex = -1) { if (itemIndex === -1) { this.modalDescription = sprintf( @@ -92,17 +95,11 @@ export default { formatSize(size) { return numberToHumanSize(size); }, - removeModalEvents() { - this.$refs.deleteModal.$refs.modal.$off('ok'); - }, deleteSingleItem(index) { this.setModalDescription(index); this.itemsToBeDeleted = [index]; - - this.$refs.deleteModal.$refs.modal.$once('ok', () => { - this.removeModalEvents(); - this.handleSingleDelete(this.repo.list[index]); - }); + this.track('click_button'); + this.$refs.deleteModal.show(); }, deleteMultipleItems() { this.itemsToBeDeleted = [...this.selectedItems]; @@ -111,17 +108,14 @@ export default { } else if (this.selectedItems.length > 1) { this.setModalDescription(); } - - this.$refs.deleteModal.$refs.modal.$once('ok', () => { - this.removeModalEvents(); - this.handleMultipleDelete(); - }); + this.track('click_button'); + this.$refs.deleteModal.show(); }, handleSingleDelete(itemToDelete) { this.itemsToBeDeleted = []; this.deleteItem(itemToDelete) .then(() => this.fetchList({ repo: this.repo })) - .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); + .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE)); }, handleMultipleDelete() { const { itemsToBeDeleted } = this; @@ -134,19 +128,16 @@ export default { items: itemsToBeDeleted.map(x => this.repo.list[x].tag), }) .then(() => this.fetchList({ repo: this.repo })) - .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); + .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE)); } else { - this.showError(errorMessagesTypes.DELETE_REGISTRY); + createFlash(DELETE_REGISTRY_ERROR_MESSAGE); } }, onPageChange(pageNumber) { this.fetchList({ repo: this.repo, page: pageNumber }).catch(() => - this.showError(errorMessagesTypes.FETCH_REGISTRY), + createFlash(FETCH_REGISTRY_ERROR_MESSAGE), ); }, - showError(message) { - createFlash(errorMessages[message]); - }, onSelectAllChange() { if (this.selectAllChecked) { this.deselectAll(); @@ -179,6 +170,15 @@ export default { canDeleteRow(item) { return item && item.canDelete && !this.isDeleteDisabled; }, + onDeletionConfirmed() { + this.track('confirm_delete'); + if (this.isMultiDelete) { + this.handleMultipleDelete(); + } else { + const index = this.itemsToBeDeleted[0]; + this.handleSingleDelete(this.repo.list[index]); + } + }, }, }; </script> @@ -202,12 +202,10 @@ export default { <th> <gl-button v-if="canDeleteRepo" + ref="bulkDeleteButton" v-gl-tooltip - v-gl-modal="modalId" :disabled="!selectedItems || selectedItems.length === 0" - class="js-delete-registry float-right" - data-track-event="click_button" - data-track-label="bulk_registry_tag_delete" + class="float-right" variant="danger" :title="s__('ContainerRegistry|Remove selected tags')" :aria-label="s__('ContainerRegistry|Remove selected tags')" @@ -259,11 +257,8 @@ export default { <td class="content action-buttons"> <gl-button v-if="canDeleteRow(item)" - v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove tag')" :aria-label="s__('ContainerRegistry|Remove tag')" - data-track-event="click_button" - data-track-label="registry_tag_delete" variant="danger" class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon" @click="deleteSingleItem(index)" @@ -282,7 +277,13 @@ export default { class="js-registry-pagination" /> - <gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger"> + <gl-modal + ref="deleteModal" + :modal-id="modalId" + ok-variant="danger" + @ok="onDeletionConfirmed" + @cancel="track('cancel_delete')" + > <template v-slot:modal-title>{{ modalAction }}</template> <template v-slot:modal-ok>{{ modalAction }}</template> <p v-html="modalDescription"></p> diff --git a/app/assets/javascripts/registry/constants.js b/app/assets/javascripts/registry/constants.js index 712b0fade3d..db798fb88ac 100644 --- a/app/assets/javascripts/registry/constants.js +++ b/app/assets/javascripts/registry/constants.js @@ -1,15 +1,8 @@ import { __ } from '../locale'; -export const errorMessagesTypes = { - FETCH_REGISTRY: 'FETCH_REGISTRY', - FETCH_REPOS: 'FETCH_REPOS', - DELETE_REPO: 'DELETE_REPO', - DELETE_REGISTRY: 'DELETE_REGISTRY', -}; - -export const errorMessages = { - [errorMessagesTypes.FETCH_REGISTRY]: __('Something went wrong while fetching the registry list.'), - [errorMessagesTypes.FETCH_REPOS]: __('Something went wrong while fetching the projects.'), - [errorMessagesTypes.DELETE_REPO]: __('Something went wrong on our end.'), - [errorMessagesTypes.DELETE_REGISTRY]: __('Something went wrong on our end.'), -}; +export const FETCH_REGISTRY_ERROR_MESSAGE = __( + 'Something went wrong while fetching the registry list.', +); +export const FETCH_REPOS_ERROR_MESSAGE = __('Something went wrong while fetching the projects.'); +export const DELETE_REPO_ERROR_MESSAGE = __('Something went wrong on our end.'); +export const DELETE_REGISTRY_ERROR_MESSAGE = __('Something went wrong on our end.'); diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js index 2121f518a7a..6afba618486 100644 --- a/app/assets/javascripts/registry/stores/actions.js +++ b/app/assets/javascripts/registry/stores/actions.js @@ -1,7 +1,7 @@ import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import * as types from './mutation_types'; -import { errorMessages, errorMessagesTypes } from '../constants'; +import { FETCH_REPOS_ERROR_MESSAGE, FETCH_REGISTRY_ERROR_MESSAGE } from '../constants'; export const fetchRepos = ({ commit, state }) => { commit(types.TOGGLE_MAIN_LOADING); @@ -14,7 +14,7 @@ export const fetchRepos = ({ commit, state }) => { }) .catch(() => { commit(types.TOGGLE_MAIN_LOADING); - createFlash(errorMessages[errorMessagesTypes.FETCH_REPOS]); + createFlash(FETCH_REPOS_ERROR_MESSAGE); }); }; @@ -30,7 +30,7 @@ export const fetchList = ({ commit }, { repo, page }) => { }) .catch(() => { commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); - createFlash(errorMessages[errorMessagesTypes.FETCH_REGISTRY]); + createFlash(FETCH_REGISTRY_ERROR_MESSAGE); }); }; diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js index ea5925247d1..419de848883 100644 --- a/app/assets/javascripts/registry/stores/mutations.js +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -1,33 +1,31 @@ import * as types from './mutation_types'; -import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; export default { [types.SET_MAIN_ENDPOINT](state, endpoint) { - Object.assign(state, { endpoint }); + state.endpoint = endpoint; }, [types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) { - Object.assign(state, { isDeleteDisabled }); + state.isDeleteDisabled = isDeleteDisabled; }, [types.SET_REPOS_LIST](state, list) { - Object.assign(state, { - repos: list.map(el => ({ - canDelete: Boolean(el.destroy_path), - destroyPath: el.destroy_path, - id: el.id, - isLoading: false, - list: [], - location: el.location, - name: el.path, - tagsPath: el.tags_path, - projectId: el.project_id, - })), - }); + state.repos = list.map(el => ({ + canDelete: Boolean(el.destroy_path), + destroyPath: el.destroy_path, + id: el.id, + isLoading: false, + list: [], + location: el.location, + name: el.path, + tagsPath: el.tags_path, + projectId: el.project_id, + })); }, [types.TOGGLE_MAIN_LOADING](state) { - Object.assign(state, { isLoading: !state.isLoading }); + state.isLoading = !state.isLoading; }, [types.SET_REGISTRY_LIST](state, { repo, resp, headers }) { diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js index 69b3d20914a..a530c4a99e2 100644 --- a/app/assets/javascripts/tree.js +++ b/app/assets/javascripts/tree.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, class-methods-use-this */ +/* eslint-disable func-names, consistent-return, one-var, no-else-return, class-methods-use-this */ import $ from 'jquery'; import { visitUrl } from './lib/utils/url_utility'; @@ -9,9 +9,8 @@ export default class TreeView { // Code browser tree slider // Make the entire tree-item row clickable, but not if clicking another link (like a commit message) $('.tree-content-holder .tree-item').on('click', function(e) { - var $clickedEl, path; - $clickedEl = $(e.target); - path = $('.tree-item-file-name a', this).attr('href'); + const $clickedEl = $(e.target); + const path = $('.tree-item-file-name a', this).attr('href'); if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) { if (e.metaKey || e.which === 2) { e.preventDefault(); @@ -26,11 +25,10 @@ export default class TreeView { } initKeyNav() { - var li, liSelected; - li = $('tr.tree-item'); - liSelected = null; + const li = $('tr.tree-item'); + let liSelected = null; return $('body').keydown(e => { - var next, path; + let next, path; if ($('input:focus').length > 0 && (e.which === 38 || e.which === 40)) { return false; } diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index d6dfe9eded8..f8e010c4f42 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -17,9 +17,11 @@ * /> */ import $ from 'jquery'; -import { mapGetters } from 'vuex'; +import { mapGetters, mapActions } from 'vuex'; +import { GlSkeletonLoading } from '@gitlab/ui'; import noteHeader from '~/notes/components/note_header.vue'; import Icon from '~/vue_shared/components/icon.vue'; +import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; import TimelineEntryItem from './timeline_entry_item.vue'; import { spriteIcon } from '../../../lib/utils/common_utils'; import initMRPopovers from '~/mr_popover/'; @@ -32,7 +34,9 @@ export default { Icon, noteHeader, TimelineEntryItem, + GlSkeletonLoading, }, + mixins: [descriptionVersionHistoryMixin], props: { note: { type: Object, @@ -75,13 +79,16 @@ export default { mounted() { initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request')); }, + methods: { + ...mapActions(['fetchDescriptionVersion']), + }, }; </script> <template> <timeline-entry-item :id="noteAnchorId" - :class="{ target: isTargetNote }" + :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" class="note system-note note-wrapper" > <div class="timeline-icon" v-html="iconHtml"></div> @@ -89,14 +96,18 @@ export default { <div class="note-header"> <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> <span v-html="actionTextHtml"></span> + <template v-if="canSeeDescriptionVersion" slot="extra-controls"> + · + <button type="button" class="btn-blank btn-link" @click="toggleDescriptionVersion"> + {{ __('Compare with previous version') }} + <icon :name="descriptionVersionToggleIcon" :size="12" class="append-left-5" /> + </button> + </template> </note-header> </div> <div class="note-body"> <div - :class="{ - 'system-note-commit-list': hasMoreCommits, - 'hide-shade': expanded, - }" + :class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }" class="note-text md" v-html="note.note_html" ></div> @@ -106,6 +117,12 @@ export default { <span>{{ __('Toggle commit list') }}</span> </div> </div> + <div v-if="shouldShowDescriptionVersion" class="description-version pt-2"> + <pre v-if="isLoadingDescriptionVersion" class="loading-state"> + <gl-skeleton-loading /> + </pre> + <pre v-else class="wrapper mt-2" v-html="descriptionVersion"></pre> + </div> </div> </div> </timeline-entry-item> diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 657e7023111..d604d97d270 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -1,7 +1,7 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); .flash-container { - margin-top: 10px; + margin: 0; margin-bottom: $gl-padding; font-size: 14px; position: relative; @@ -41,6 +41,7 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); .flash-success, .flash-warning { padding: $gl-padding $gl-padding-32 $gl-padding ($gl-padding + $gl-padding-4); + margin-top: 10px; .container-fluid, .container-fluid.container-limited { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 21a9f143039..1da9f691639 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -310,6 +310,17 @@ $note-form-margin-left: 72px; .note-body { overflow: hidden; + .description-version { + pre { + max-height: $dropdown-max-height-lg; + white-space: pre-wrap; + + &.loading-state { + height: 94px; + } + } + } + .system-note-commit-list-toggler { color: $blue-600; padding: 10px 0 0; diff --git a/app/finders/prometheus_metrics_finder.rb b/app/finders/prometheus_metrics_finder.rb new file mode 100644 index 00000000000..84a071abbd5 --- /dev/null +++ b/app/finders/prometheus_metrics_finder.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +class PrometheusMetricsFinder + ACCEPTED_PARAMS = [ + :project, + :group, + :title, + :y_label, + :identifier, + :id, + :common, + :ordered + ].freeze + + # Cautiously preferring a memoized class method over a constant + # so that the DB connection is accessed after the class is loaded. + def self.indexes + @indexes ||= PrometheusMetric + .connection + .indexes(:prometheus_metrics) + .map { |index| index.columns.map(&:to_sym) } + end + + def initialize(params = {}) + @params = params.slice(*ACCEPTED_PARAMS) + end + + # @return [PrometheusMetric, PrometheusMetric::ActiveRecord_Relation] + def execute + validate_params! + + metrics = by_project(::PrometheusMetric.all) + metrics = by_group(metrics) + metrics = by_title(metrics) + metrics = by_y_label(metrics) + metrics = by_common(metrics) + metrics = by_ordered(metrics) + metrics = by_identifier(metrics) + metrics = by_id(metrics) + + metrics + end + + private + + attr_reader :params + + def by_project(metrics) + return metrics unless params[:project] + + metrics.for_project(params[:project]) + end + + def by_group(metrics) + return metrics unless params[:group] + + metrics.for_group(params[:group]) + end + + def by_title(metrics) + return metrics unless params[:title] + + metrics.for_title(params[:title]) + end + + def by_y_label(metrics) + return metrics unless params[:y_label] + + metrics.for_y_label(params[:y_label]) + end + + def by_common(metrics) + return metrics unless params[:common] + + metrics.common + end + + def by_ordered(metrics) + return metrics unless params[:ordered] + + metrics.ordered + end + + def by_identifier(metrics) + return metrics unless params[:identifier] + + metrics.for_identifier(params[:identifier]) + end + + def by_id(metrics) + return metrics unless params[:id] + + metrics.id_in(params[:id]) + end + + def validate_params! + validate_params_present! + validate_id_params! + validate_indexes! + end + + # Ensure all provided params are supported + def validate_params_present! + raise ArgumentError, "Please provide one or more of: #{ACCEPTED_PARAMS}" if params.blank? + end + + # Protect against the caller "finding" the wrong metric + def validate_id_params! + raise ArgumentError, 'Only one of :identifier, :id is permitted' if params[:identifier] && params[:id] + raise ArgumentError, ':identifier must be scoped to a :project or :common' if params[:identifier] && !(params[:project] || params[:common]) + end + + # Protect against unaccounted-for, complex/slow queries. + # This is not a hard and fast rule, but is meant to encourage + # mindful inclusion of new queries. + def validate_indexes! + indexable_params = params.except(:ordered, :id, :project).keys + indexable_params << :project_id if params[:project] + indexable_params.sort! + + raise ArgumentError, "An index should exist for params: #{indexable_params}" unless appropriate_index?(indexable_params) + end + + def appropriate_index?(indexable_params) + return true if indexable_params.blank? + + self.class.indexes.any? { |index| (index - indexable_params).empty? } + end +end diff --git a/app/models/description_version.rb b/app/models/description_version.rb index abab7f94212..05362a2f90b 100644 --- a/app/models/description_version.rb +++ b/app/models/description_version.rb @@ -10,6 +10,10 @@ class DescriptionVersion < ApplicationRecord %i(issue merge_request).freeze end + def issuable + issue || merge_request + end + private def exactly_one_issuable diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb index 08f4df7ea01..d0dc31476ff 100644 --- a/app/models/prometheus_metric.rb +++ b/app/models/prometheus_metric.rb @@ -14,7 +14,13 @@ class PrometheusMetric < ApplicationRecord validates :project, presence: true, unless: :common? validates :project, absence: true, if: :common? + scope :for_project, -> (project) { where(project: project) } + scope :for_group, -> (group) { where(group: group) } + scope :for_title, -> (title) { where(title: title) } + scope :for_y_label, -> (y_label) { where(y_label: y_label) } + scope :for_identifier, -> (identifier) { where(identifier: identifier) } scope :common, -> { where(common: true) } + scope :ordered, -> { reorder(created_at: :asc) } def priority group_details(group).fetch(:priority) diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 1d3b59eb1b7..c49dec2a93c 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -79,3 +79,5 @@ class NoteEntity < API::Entities::Note request.current_user end end + +NoteEntity.prepend_if_ee('EE::NoteEntity') diff --git a/app/services/metrics/dashboard/custom_metric_embed_service.rb b/app/services/metrics/dashboard/custom_metric_embed_service.rb index dce713fbda7..79a556b1695 100644 --- a/app/services/metrics/dashboard/custom_metric_embed_service.rb +++ b/app/services/metrics/dashboard/custom_metric_embed_service.rb @@ -77,15 +77,14 @@ module Metrics # There may be multiple metrics, but they should be # displayed in a single panel/chart. # @return [ActiveRecord::AssociationRelation<PromtheusMetric>] - # rubocop: disable CodeReuse/ActiveRecord def metrics - project.prometheus_metrics.where( + PrometheusMetricsFinder.new( + project: project, group: group_key, title: title, y_label: y_label - ) + ).execute end - # rubocop: enable CodeReuse/ActiveRecord # Returns a symbol representing the group that # the dashboard's group title belongs to. diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 706fa033c51..cd07fee8e59 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -152,7 +152,7 @@ - email = " (#{@user.unconfirmed_email})" %p This user has an unconfirmed email address#{email}. You may force a confirmation. %br - = link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' } + = link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?', qa_selector: 'confirm_user_button' } = render_if_exists 'admin/users/user_detail_note' diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index be78fd0ccfb..9db6184ebca 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -2,6 +2,7 @@ - model = local_assigns.fetch(:model) - form = local_assigns.fetch(:form) +- placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a comment or drag your files here…') - supports_quick_actions = model.new_record? - if supports_quick_actions @@ -16,7 +17,7 @@ = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render 'projects/zen', f: form, attr: :description, classes: 'note-textarea qa-issuable-form-description rspec-issuable-form-description', - placeholder: "Write a comment or drag your files here…", + placeholder: placeholder, supports_quick_actions: supports_quick_actions = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions .clearfix diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml index eac743b5206..b4b06640bd9 100644 --- a/app/views/shared/members/_access_request_links.html.haml +++ b/app/views/shared/members/_access_request_links.html.haml @@ -4,7 +4,7 @@ - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project') = link_to link_text, polymorphic_path([:leave, source, :members]), method: :delete, - data: { confirm: leave_confirmation_message(source) }, + data: { confirm: leave_confirmation_message(source), qa_selector: 'leave_group_link' }, class: 'access-request-link js-leave-link' - elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]), diff --git a/changelogs/unreleased/13135-call-to-validate_query-in-custom-metrics-form-is-not-retried.yml b/changelogs/unreleased/13135-call-to-validate_query-in-custom-metrics-form-is-not-retried.yml new file mode 100644 index 00000000000..2728d5213c1 --- /dev/null +++ b/changelogs/unreleased/13135-call-to-validate_query-in-custom-metrics-form-is-not-retried.yml @@ -0,0 +1,5 @@ +--- +title: Fix query validation in custom metrics form +merge_request: 18769 +author: +type: fixed diff --git a/changelogs/unreleased/30616-improve-merge-request-description-placeholder.yml b/changelogs/unreleased/30616-improve-merge-request-description-placeholder.yml new file mode 100644 index 00000000000..942450313be --- /dev/null +++ b/changelogs/unreleased/30616-improve-merge-request-description-placeholder.yml @@ -0,0 +1,5 @@ +--- +title: Improve merge request description placeholder +merge_request: 20032 +author: Jacopo Beschi @jacopo-beschi +type: changed diff --git a/changelogs/unreleased/35528-add-ui-event-tracking-for-container-registry.yml b/changelogs/unreleased/35528-add-ui-event-tracking-for-container-registry.yml new file mode 100644 index 00000000000..9b1ae378fef --- /dev/null +++ b/changelogs/unreleased/35528-add-ui-event-tracking-for-container-registry.yml @@ -0,0 +1,5 @@ +--- +title: Add event tracking to container registry +merge_request: 19772 +author: +type: changed diff --git a/changelogs/unreleased/36262-monitor-cluster-health-charts-does-not-load.yml b/changelogs/unreleased/36262-monitor-cluster-health-charts-does-not-load.yml new file mode 100644 index 00000000000..95817e6010e --- /dev/null +++ b/changelogs/unreleased/36262-monitor-cluster-health-charts-does-not-load.yml @@ -0,0 +1,5 @@ +--- +title: Fix broken monitor cluster health dashboard +merge_request: 20120 +author: +type: fixed diff --git a/changelogs/unreleased/36453-flashcontainer-causing-page-header-to-be-off-center.yml b/changelogs/unreleased/36453-flashcontainer-causing-page-header-to-be-off-center.yml new file mode 100644 index 00000000000..12f44a5f661 --- /dev/null +++ b/changelogs/unreleased/36453-flashcontainer-causing-page-header-to-be-off-center.yml @@ -0,0 +1,5 @@ +--- +title: Move margin-top from flash container to flash +merge_request: 20211 +author: +type: other diff --git a/changelogs/unreleased/remove_var_from_bootstrap_jquery_spec_js.yml b/changelogs/unreleased/remove_var_from_bootstrap_jquery_spec_js.yml new file mode 100644 index 00000000000..940404031c9 --- /dev/null +++ b/changelogs/unreleased/remove_var_from_bootstrap_jquery_spec_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from bootstrap_jquery_spec.js +merge_request: 20089 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_issue_js.yml b/changelogs/unreleased/remove_var_from_issue_js.yml new file mode 100644 index 00000000000..ee5cf59fb56 --- /dev/null +++ b/changelogs/unreleased/remove_var_from_issue_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from issue.js +merge_request: 20098 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_labels_select_js.yml b/changelogs/unreleased/remove_var_from_labels_select_js.yml new file mode 100644 index 00000000000..7186fe4b2f8 --- /dev/null +++ b/changelogs/unreleased/remove_var_from_labels_select_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from labels_select.js +merge_request: 20153 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/remove_var_from_tree_js.yml b/changelogs/unreleased/remove_var_from_tree_js.yml new file mode 100644 index 00000000000..9738a44c4c8 --- /dev/null +++ b/changelogs/unreleased/remove_var_from_tree_js.yml @@ -0,0 +1,5 @@ +--- +title: Remove var from tree.js +merge_request: 20103 +author: Lee Tickett +type: other diff --git a/db/fixtures/development/02_users.rb b/db/fixtures/development/02_users.rb new file mode 100644 index 00000000000..6e0b37d7258 --- /dev/null +++ b/db/fixtures/development/02_users.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class Gitlab::Seeder::Users + include ActionView::Helpers::NumberHelper + + RANDOM_USERS_COUNT = 20 + MASS_USERS_COUNT = ENV['CI'] ? 10 : 1_000_000 + MASS_INSERT_USERNAME_START = 'mass_insert_user_' + + attr_reader :opts + + def initialize(opts = {}) + @opts = opts + end + + def seed! + Sidekiq::Testing.inline! do + create_mass_users! + create_random_users! + end + end + + private + + def create_mass_users! + encrypted_password = Devise::Encryptor.digest(User, '12345678') + + Gitlab::Seeder.with_mass_insert(MASS_USERS_COUNT, User) do + ActiveRecord::Base.connection.execute <<~SQL + INSERT INTO users (username, name, email, confirmed_at, projects_limit, encrypted_password) + SELECT + '#{MASS_INSERT_USERNAME_START}' || seq, + 'Seed user ' || seq, + 'seed_user' || seq || '@example.com', + to_timestamp(seq), + #{MASS_USERS_COUNT}, + '#{encrypted_password}' + FROM generate_series(1, #{MASS_USERS_COUNT}) AS seq + SQL + end + + relation = User.where(admin: false) + Gitlab::Seeder.with_mass_insert(relation.count, Namespace) do + ActiveRecord::Base.connection.execute <<~SQL + INSERT INTO namespaces (name, path, owner_id) + SELECT + username, + username, + id + FROM users WHERE NOT admin + SQL + end + end + + def create_random_users! + RANDOM_USERS_COUNT.times do |i| + begin + User.create!( + username: FFaker::Internet.user_name, + name: FFaker::Name.name, + email: FFaker::Internet.email, + confirmed_at: DateTime.now, + password: '12345678' + ) + + print '.' + rescue ActiveRecord::RecordInvalid + print 'F' + end + end + end +end + +Gitlab::Seeder.quiet do + users = Gitlab::Seeder::Users.new + users.seed! +end diff --git a/db/fixtures/development/03_project.rb b/db/fixtures/development/03_project.rb index 46018cf68aa..87ef65276eb 100644 --- a/db/fixtures/development/03_project.rb +++ b/db/fixtures/development/03_project.rb @@ -1,137 +1,210 @@ require './spec/support/sidekiq' -# rubocop:disable Rails/Output - -Sidekiq::Testing.inline! do - Gitlab::Seeder.quiet do - Gitlab::Seeder.without_gitaly_timeout do - project_urls = %w[ - https://gitlab.com/gitlab-org/gitlab-test.git - https://gitlab.com/gitlab-org/gitlab-shell.git - https://gitlab.com/gnuwget/wget2.git - https://gitlab.com/Commit451/LabCoat.git - https://github.com/jashkenas/underscore.git - https://github.com/flightjs/flight.git - https://github.com/twitter/typeahead.js.git - https://github.com/h5bp/html5-boilerplate.git - https://github.com/google/material-design-lite.git - https://github.com/jlevy/the-art-of-command-line.git - https://github.com/FreeCodeCamp/freecodecamp.git - https://github.com/google/deepdream.git - https://github.com/jtleek/datasharing.git - https://github.com/WebAssembly/design.git - https://github.com/airbnb/javascript.git - https://github.com/tessalt/echo-chamber-js.git - https://github.com/atom/atom.git - https://github.com/mattermost/mattermost-server.git - https://github.com/purifycss/purifycss.git - https://github.com/facebook/nuclide.git - https://github.com/wbkd/awesome-d3.git - https://github.com/kilimchoi/engineering-blogs.git - https://github.com/gilbarbara/logos.git - https://github.com/reduxjs/redux.git - https://github.com/awslabs/s2n.git - https://github.com/arkency/reactjs_koans.git - https://github.com/twbs/bootstrap.git - https://github.com/chjj/ttystudio.git - https://github.com/MostlyAdequate/mostly-adequate-guide.git - https://github.com/octocat/Spoon-Knife.git - https://github.com/opencontainers/runc.git - https://github.com/googlesamples/android-topeka.git - ] - - large_project_urls = %w[ - https://github.com/torvalds/linux.git - https://gitlab.gnome.org/GNOME/gimp.git - https://gitlab.gnome.org/GNOME/gnome-mud.git - https://gitlab.com/fdroid/fdroidclient.git - https://gitlab.com/inkscape/inkscape.git - https://github.com/gnachman/iTerm2.git - ] - - def create_project(url, force_latest_storage: false) - group_path, project_path = url.split('/')[-2..-1] - - group = Group.find_by(path: group_path) - - unless group - group = Group.new( - name: group_path.titleize, - path: group_path - ) - group.description = FFaker::Lorem.sentence - group.save! - - group.add_owner(User.first) - end +class Gitlab::Seeder::Projects + include ActionView::Helpers::NumberHelper + + PROJECT_URLS = %w[ + https://gitlab.com/gitlab-org/gitlab-test.git + https://gitlab.com/gitlab-org/gitlab-shell.git + https://gitlab.com/gnuwget/wget2.git + https://gitlab.com/Commit451/LabCoat.git + https://github.com/jashkenas/underscore.git + https://github.com/flightjs/flight.git + https://github.com/twitter/typeahead.js.git + https://github.com/h5bp/html5-boilerplate.git + https://github.com/google/material-design-lite.git + https://github.com/jlevy/the-art-of-command-line.git + https://github.com/FreeCodeCamp/freecodecamp.git + https://github.com/google/deepdream.git + https://github.com/jtleek/datasharing.git + https://github.com/WebAssembly/design.git + https://github.com/airbnb/javascript.git + https://github.com/tessalt/echo-chamber-js.git + https://github.com/atom/atom.git + https://github.com/mattermost/mattermost-server.git + https://github.com/purifycss/purifycss.git + https://github.com/facebook/nuclide.git + https://github.com/wbkd/awesome-d3.git + https://github.com/kilimchoi/engineering-blogs.git + https://github.com/gilbarbara/logos.git + https://github.com/reduxjs/redux.git + https://github.com/awslabs/s2n.git + https://github.com/arkency/reactjs_koans.git + https://github.com/twbs/bootstrap.git + https://github.com/chjj/ttystudio.git + https://github.com/MostlyAdequate/mostly-adequate-guide.git + https://github.com/octocat/Spoon-Knife.git + https://github.com/opencontainers/runc.git + https://github.com/googlesamples/android-topeka.git + ] + LARGE_PROJECT_URLS = %w[ + https://github.com/torvalds/linux.git + https://gitlab.gnome.org/GNOME/gimp.git + https://gitlab.gnome.org/GNOME/gnome-mud.git + https://gitlab.com/fdroid/fdroidclient.git + https://gitlab.com/inkscape/inkscape.git + https://github.com/gnachman/iTerm2.git + ] + # Consider altering MASS_USERS_COUNT for less + # users with projects. + MASS_PROJECTS_COUNT_PER_USER = { + private: 3, # 3m projects + + internal: 1, # 1m projects + + public: 1 # 1m projects = 5m total + } + MASS_INSERT_NAME_START = 'mass_insert_project_' + + def seed! + Sidekiq::Testing.inline! do + create_real_projects! + create_large_projects! + create_mass_projects! + end + end - project_path.gsub!(".git", "") + private - params = { - import_url: url, - namespace_id: group.id, - name: project_path.titleize, - description: FFaker::Lorem.sentence, - visibility_level: Gitlab::VisibilityLevel.values.sample, - skip_disk_validation: true - } + def create_real_projects! + # You can specify how many projects you need during seed execution + size = ENV['SIZE'].present? ? ENV['SIZE'].to_i : 8 - if force_latest_storage - params[:storage_version] = Project::LATEST_STORAGE_VERSION - end + PROJECT_URLS.first(size).each_with_index do |url, i| + create_real_project!(url, force_latest_storage: i.even?) + end + end - project = nil + def create_large_projects! + return unless ENV['LARGE_PROJECTS'].present? - Sidekiq::Worker.skipping_transaction_check do - project = Projects::CreateService.new(User.first, params).execute + LARGE_PROJECT_URLS.each(&method(:create_real_project!)) - # Seed-Fu runs this entire fixture in a transaction, so the `after_commit` - # hook won't run until after the fixture is loaded. That is too late - # since the Sidekiq::Testing block has already exited. Force clearing - # the `after_commit` queue to ensure the job is run now. - project.send(:_run_after_commit_queue) - project.import_state.send(:_run_after_commit_queue) - end + if ENV['FORK'].present? + puts "\nGenerating forks" - if project.valid? && project.valid_repo? + project_name = ENV['FORK'] == 'true' ? 'torvalds/linux' : ENV['FORK'] + + project = Project.find_by_full_path(project_name) + + User.offset(1).first(5).each do |user| + new_project = ::Projects::ForkService.new(project, user).execute + + if new_project.valid? && (new_project.valid_repo? || new_project.import_state.scheduled?) print '.' else - puts project.errors.full_messages + new_project.errors.full_messages.each do |error| + puts "#{new_project.full_path}: #{error}" + end print 'F' end end + end + end - # You can specify how many projects you need during seed execution - size = ENV['SIZE'].present? ? ENV['SIZE'].to_i : 8 + def create_real_project!(url, force_latest_storage: false) + group_path, project_path = url.split('/')[-2..-1] - project_urls.first(size).each_with_index do |url, i| - create_project(url, force_latest_storage: i.even?) - end + group = Group.find_by(path: group_path) - if ENV['LARGE_PROJECTS'].present? - large_project_urls.each(&method(:create_project)) + unless group + group = Group.new( + name: group_path.titleize, + path: group_path + ) + group.description = FFaker::Lorem.sentence + group.save! - if ENV['FORK'].present? - puts "\nGenerating forks" + group.add_owner(User.first) + end - project_name = ENV['FORK'] == 'true' ? 'torvalds/linux' : ENV['FORK'] + project_path.gsub!(".git", "") - project = Project.find_by_full_path(project_name) + params = { + import_url: url, + namespace_id: group.id, + name: project_path.titleize, + description: FFaker::Lorem.sentence, + visibility_level: Gitlab::VisibilityLevel.values.sample, + skip_disk_validation: true + } - User.offset(1).first(5).each do |user| - new_project = Projects::ForkService.new(project, user).execute + if force_latest_storage + params[:storage_version] = Project::LATEST_STORAGE_VERSION + end - if new_project.valid? && (new_project.valid_repo? || new_project.import_state.scheduled?) - print '.' - else - new_project.errors.full_messages.each do |error| - puts "#{new_project.full_path}: #{error}" - end - print 'F' - end - end - end - end + project = nil + + Sidekiq::Worker.skipping_transaction_check do + project = ::Projects::CreateService.new(User.first, params).execute + + # Seed-Fu runs this entire fixture in a transaction, so the `after_commit` + # hook won't run until after the fixture is loaded. That is too late + # since the Sidekiq::Testing block has already exited. Force clearing + # the `after_commit` queue to ensure the job is run now. + project.send(:_run_after_commit_queue) + project.import_state.send(:_run_after_commit_queue) + end + + if project.valid? && project.valid_repo? + print '.' + else + puts project.errors.full_messages + print 'F' end end + + def create_mass_projects! + projects_per_user_count = MASS_PROJECTS_COUNT_PER_USER.values.sum + visibility_per_user = ['private'] * MASS_PROJECTS_COUNT_PER_USER.fetch(:private) + + ['internal'] * MASS_PROJECTS_COUNT_PER_USER.fetch(:internal) + + ['public'] * MASS_PROJECTS_COUNT_PER_USER.fetch(:public) + visibility_level_per_user = visibility_per_user.map { |visibility| Gitlab::VisibilityLevel.level_value(visibility) } + + visibility_per_user = visibility_per_user.join(',') + visibility_level_per_user = visibility_level_per_user.join(',') + + Gitlab::Seeder.with_mass_insert(User.count * projects_per_user_count, "Projects and relations") do + ActiveRecord::Base.connection.execute <<~SQL + INSERT INTO projects (name, path, creator_id, namespace_id, visibility_level, created_at, updated_at) + SELECT + 'Seed project ' || seq || ' ' || ('{#{visibility_per_user}}'::text[])[seq] AS project_name, + 'mass_insert_project_' || ('{#{visibility_per_user}}'::text[])[seq] || '_' || seq AS project_path, + u.id AS user_id, + n.id AS namespace_id, + ('{#{visibility_level_per_user}}'::int[])[seq] AS visibility_level, + NOW() AS created_at, + NOW() AS updated_at + FROM users u + CROSS JOIN generate_series(1, #{projects_per_user_count}) AS seq + JOIN namespaces n ON n.owner_id=u.id + SQL + + ActiveRecord::Base.connection.execute <<~SQL + INSERT INTO project_features (project_id, merge_requests_access_level, issues_access_level, wiki_access_level, + pages_access_level) + SELECT + id, + #{ProjectFeature::ENABLED} AS merge_requests_access_level, + #{ProjectFeature::ENABLED} AS issues_access_level, + #{ProjectFeature::ENABLED} AS wiki_access_level, + #{ProjectFeature::ENABLED} AS pages_access_level + FROM projects ON CONFLICT (project_id) DO NOTHING; + SQL + + ActiveRecord::Base.connection.execute <<~SQL + INSERT INTO routes (source_id, source_type, name, path) + SELECT + p.id, + 'Project', + u.name || ' / ' || p.name, + u.username || '/' || p.path + FROM projects p JOIN users u ON u.id=p.creator_id + ON CONFLICT (source_type, source_id) DO NOTHING; + SQL + end + end +end + +Gitlab::Seeder.quiet do + projects = Gitlab::Seeder::Projects.new + projects.seed! end diff --git a/db/fixtures/development/04_labels.rb b/db/fixtures/development/04_labels.rb index b9ae4098d76..21d552c89f5 100644 --- a/db/fixtures/development/04_labels.rb +++ b/db/fixtures/development/04_labels.rb @@ -43,7 +43,7 @@ Gitlab::Seeder.quiet do end puts "\nGenerating project labels" - Project.all.find_each do |project| + Project.not_mass_generated.find_each do |project| Gitlab::Seeder::ProjectLabels.new(project).seed! end end diff --git a/db/fixtures/development/05_users.rb b/db/fixtures/development/05_users.rb deleted file mode 100644 index 101ff3a1209..00000000000 --- a/db/fixtures/development/05_users.rb +++ /dev/null @@ -1,34 +0,0 @@ -require './spec/support/sidekiq' - -Gitlab::Seeder.quiet do - 20.times do |i| - begin - User.create!( - username: FFaker::Internet.user_name, - name: FFaker::Name.name, - email: FFaker::Internet.email, - confirmed_at: DateTime.now, - password: '12345678' - ) - - print '.' - rescue ActiveRecord::RecordInvalid - print 'F' - end - end - - 5.times do |i| - begin - User.create!( - username: "user#{i}", - name: "User #{i}", - email: "user#{i}@example.com", - confirmed_at: DateTime.now, - password: '12345678' - ) - print '.' - rescue ActiveRecord::RecordInvalid - print 'F' - end - end -end diff --git a/db/fixtures/development/06_teams.rb b/db/fixtures/development/06_teams.rb index b218f4e71fd..79ea96bf30e 100644 --- a/db/fixtures/development/06_teams.rb +++ b/db/fixtures/development/06_teams.rb @@ -3,7 +3,7 @@ require './spec/support/sidekiq' Sidekiq::Testing.inline! do Gitlab::Seeder.quiet do Group.all.each do |group| - User.all.sample(4).each do |user| + User.not_mass_generated.sample(4).each do |user| if group.add_user(user, Gitlab::Access.values.sample).persisted? print '.' else @@ -12,8 +12,8 @@ Sidekiq::Testing.inline! do end end - Project.all.each do |project| - User.all.sample(4).each do |user| + Project.not_mass_generated.each do |project| + User.not_mass_generated.sample(4).each do |user| if project.add_role(user, Gitlab::Access.sym_options.keys.sample) print '.' else diff --git a/db/fixtures/development/07_milestones.rb b/db/fixtures/development/07_milestones.rb index 271bfbc97e0..1194bb3fe6f 100644 --- a/db/fixtures/development/07_milestones.rb +++ b/db/fixtures/development/07_milestones.rb @@ -1,7 +1,7 @@ require './spec/support/sidekiq' Gitlab::Seeder.quiet do - Project.all.each do |project| + Project.not_mass_generated.each do |project| 5.times do |i| milestone_params = { title: "v#{i}.0", diff --git a/db/fixtures/development/10_merge_requests.rb b/db/fixtures/development/10_merge_requests.rb index 4af545614f7..29f2fabbd5f 100644 --- a/db/fixtures/development/10_merge_requests.rb +++ b/db/fixtures/development/10_merge_requests.rb @@ -4,7 +4,13 @@ Gitlab::Seeder.quiet do # Limit the number of merge requests per project to avoid long seeds MAX_NUM_MERGE_REQUESTS = 10 - Project.non_archived.with_merge_requests_enabled.reject(&:empty_repo?).each do |project| + projects = Project + .non_archived + .with_merge_requests_enabled + .not_mass_generated + .reject(&:empty_repo?) + + projects.each do |project| branches = project.repository.branch_names.sample(MAX_NUM_MERGE_REQUESTS * 2) branches.each do |branch_name| diff --git a/db/fixtures/development/11_keys.rb b/db/fixtures/development/11_keys.rb index c405ecfdaf3..13eadc35e07 100644 --- a/db/fixtures/development/11_keys.rb +++ b/db/fixtures/development/11_keys.rb @@ -9,7 +9,7 @@ Sidekiq::Testing.disable! do # that it falls under `Sidekiq::Testing.disable!`. Key.skip_callback(:commit, :after, :add_to_shell) - User.first(10).each do |user| + User.not_mass_generated.first(10).each do |user| key = "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt#{user.id + 100}6k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=" key = user.keys.create( diff --git a/db/fixtures/development/12_snippets.rb b/db/fixtures/development/12_snippets.rb index a9f4069a0f8..0ee9058a20b 100644 --- a/db/fixtures/development/12_snippets.rb +++ b/db/fixtures/development/12_snippets.rb @@ -25,7 +25,7 @@ end eos 50.times do |i| - user = User.all.sample + user = User.not_mass_generated.sample PersonalSnippet.seed(:id, [{ id: i, diff --git a/db/fixtures/development/14_pipelines.rb b/db/fixtures/development/14_pipelines.rb index 5c8b681fa92..468caac23f9 100644 --- a/db/fixtures/development/14_pipelines.rb +++ b/db/fixtures/development/14_pipelines.rb @@ -214,7 +214,7 @@ class Gitlab::Seeder::Pipelines end Gitlab::Seeder.quiet do - Project.all.sample(5).each do |project| + Project.not_mass_generated.sample(5).each do |project| project_builds = Gitlab::Seeder::Pipelines.new(project) project_builds.seed! end diff --git a/db/fixtures/development/16_protected_branches.rb b/db/fixtures/development/16_protected_branches.rb index 39d466fb43f..2b492ac1f61 100644 --- a/db/fixtures/development/16_protected_branches.rb +++ b/db/fixtures/development/16_protected_branches.rb @@ -3,7 +3,7 @@ require './spec/support/sidekiq' Gitlab::Seeder.quiet do admin_user = User.find(1) - Project.all.each do |project| + Project.not_mass_generated.each do |project| params = { name: 'master' } diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb index b7ddeef95b8..606a4cb1dde 100644 --- a/db/fixtures/development/17_cycle_analytics.rb +++ b/db/fixtures/development/17_cycle_analytics.rb @@ -217,7 +217,7 @@ Gitlab::Seeder.quiet do flag = 'SEED_CYCLE_ANALYTICS' if ENV[flag] - Project.find_each do |project| + Project.not_mass_generated.find_each do |project| # This seed naively assumes that every project has a repository, and every # repository has a `master` branch, which may be the case for a pristine # GDK seed, but is almost never true for a GDK that's actually had diff --git a/db/fixtures/development/19_environments.rb b/db/fixtures/development/19_environments.rb index 3e227928a29..08363804216 100644 --- a/db/fixtures/development/19_environments.rb +++ b/db/fixtures/development/19_environments.rb @@ -67,7 +67,7 @@ class Gitlab::Seeder::Environments end Gitlab::Seeder.quiet do - Project.all.sample(5).each do |project| + Project.not_mass_generated.sample(5).each do |project| project_environments = Gitlab::Seeder::Environments.new(project) project_environments.seed! end diff --git a/db/fixtures/development/23_spam_logs.rb b/db/fixtures/development/23_spam_logs.rb index 81cc13e6b2d..4a839f5bc23 100644 --- a/db/fixtures/development/23_spam_logs.rb +++ b/db/fixtures/development/23_spam_logs.rb @@ -22,7 +22,7 @@ module Db end def self.random_user - User.find(User.pluck(:id).sample) + User.find(User.not_mass_generated.pluck(:id).sample) end end end diff --git a/db/fixtures/development/24_forks.rb b/db/fixtures/development/24_forks.rb index 971c6f0d0c8..fa16b2a1d93 100644 --- a/db/fixtures/development/24_forks.rb +++ b/db/fixtures/development/24_forks.rb @@ -2,8 +2,8 @@ require './spec/support/sidekiq' Sidekiq::Testing.inline! do Gitlab::Seeder.quiet do - User.all.sample(10).each do |user| - source_project = Project.public_only.sample + User.not_mass_generated.sample(10).each do |user| + source_project = Project.not_mass_generated.public_only.sample ## # 03_project.rb might not have created a public project because diff --git a/doc/administration/packages/container_registry.md b/doc/administration/packages/container_registry.md index 2444365d5ae..a62e3ab603d 100644 --- a/doc/administration/packages/container_registry.md +++ b/doc/administration/packages/container_registry.md @@ -18,7 +18,9 @@ You can read more about the Docker Registry at **Omnibus GitLab installations** -All you have to do is configure the domain name under which the Container +If you are using the Omnibus GitLab built in [Let's Encrypt integration](https://docs.gitlab.com/omnibus/settings/ssl.html#lets-encrypt-integration), as of GitLab 12.5, the Container Registry will be automatically enabled on port 5050 of the default domain. + +If you would like to use a separate domain, all you have to do is configure the domain name under which the Container Registry will listen to. Read [#container-registry-domain-configuration](#container-registry-domain-configuration) and pick one of the two options that fits your case. diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 1eb010b1ad6..ecb7f04318a 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -1219,6 +1219,10 @@ type Epic implements Noteable { hasIssues: Boolean! id: ID! iid: ID! + + """ + A list of issues associated with the epic + """ issues( """ Returns the elements in the list that come after the specified cursor. diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 5e6a9dba4ed..b8d788fb6ec 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -3751,7 +3751,7 @@ }, { "name": "issues", - "description": null, + "description": "A list of issues associated with the epic", "args": [ { "name": "after", diff --git a/doc/development/database_debugging.md b/doc/development/database_debugging.md index 9947f9c16c0..65a3e518585 100644 --- a/doc/development/database_debugging.md +++ b/doc/development/database_debugging.md @@ -19,7 +19,7 @@ If you just want to delete everything and start over with an empty DB (~1 minute - `bundle exec rake db:reset RAILS_ENV=development` -If you just want to delete everything and start over with dummy data (~40 minutes). This also does `db:reset` and runs DB-specific migrations: +If you just want to delete everything and start over with dummy data (~4 minutes). This also does `db:reset` and runs DB-specific migrations: - `bundle exec rake dev:setup RAILS_ENV=development` diff --git a/doc/development/rake_tasks.md b/doc/development/rake_tasks.md index 229efc164c3..369806d462b 100644 --- a/doc/development/rake_tasks.md +++ b/doc/development/rake_tasks.md @@ -12,6 +12,14 @@ The `setup` task is an alias for `gitlab:setup`. This tasks calls `db:reset` to create the database, and calls `db:seed_fu` to seed the database. Note: `db:setup` calls `db:seed` but this does nothing. +### Env variables + +**MASS_INSERT**: Create millions of users (2m), projects (5m) and its +relations. It's highly recommended to run the seed with it to catch slow queries +while developing. Expect the process to take up to 20 extra minutes. + +**LARGE_PROJECTS**: Create large projects (through import) from a predefined set of urls. + ### Seeding issues for all or a given project You can seed issues for all or a given project with the `gitlab:seed:issues` diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md index bd20cc9145d..0e46052b0bd 100644 --- a/doc/user/application_security/dependency_scanning/index.md +++ b/doc/user/application_security/dependency_scanning/index.md @@ -37,7 +37,7 @@ The results are sorted by the severity of the vulnerability: ## Requirements -To run a Dependency Scanning job, you need GitLab Runner with the +To run a Dependency Scanning job, by default, you need GitLab Runner with the [`docker`](https://docs.gitlab.com/runner/executors/docker.html#use-docker-in-docker-with-privileged-mode) or [`kubernetes`](https://docs.gitlab.com/runner/install/kubernetes.html#running-privileged-containers-for-the-runners) executor running in privileged mode. If you're using the shared Runners on GitLab.com, @@ -47,6 +47,8 @@ CAUTION: **Caution:** If you use your own Runners, make sure that the Docker version you have installed is **not** `19.03.00`. See [troubleshooting information](#error-response-from-daemon-error-processing-tar-file-docker-tar-relocation-error) for details. +Privileged mode is not necessary if you've [disabled Docker in Docker for Dependency Scanning](#disabling-docker-in-docker-for-dependency-scanning) + ## Supported languages and package managers The following languages and dependency managers are supported. @@ -133,6 +135,7 @@ using environment variables. | `DS_PYTHON_VERSION` | Version of Python. If set to 2, dependencies are installed using Python 2.7 instead of Python 3.6. ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12296) in GitLab 12.1)| | | `DS_PIP_DEPENDENCY_PATH` | Path to load Python pip dependencies from. ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12412) in GitLab 12.2) | | | `DS_DEFAULT_ANALYZERS` | Override the names of the official default images. Read more about [customizing analyzers](analyzers.md). | | +| `DS_DISABLE_DIND` | Disable Docker in Docker and run analyzers [individually](#disabling-docker-in-docker-for-dependency-scanning).| | | `DS_PULL_ANALYZER_IMAGES` | Pull the images from the Docker registry (set to `0` to disable). | | | `DS_EXCLUDED_PATHS` | Exclude vulnerabilities from output based on the paths. A comma-separated list of patterns. Patterns can be globs, file or folder paths. Parent directories will also match patterns. | `DS_EXCLUDED_PATHS=doc,spec` | | `DS_DOCKER_CLIENT_NEGOTIATION_TIMEOUT` | Time limit for Docker client negotiation. Timeouts are parsed using Go's [`ParseDuration`](https://golang.org/pkg/time/#ParseDuration). Valid time units are `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`. For example, `300ms`, `1.5h`, or `2h45m`. | | @@ -168,6 +171,23 @@ so that you don't have to expose your private data in `.gitlab-ci.yml` (e.g., ad </settings> ``` +### Disabling Docker in Docker for Dependency Scanning + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12487) in GitLab Ultimate 12.5. + +You can avoid the need for Docker in Docker by running the individual analyzers. +This does not require running the executor in privileged mode. For example: + +```yaml +include: + template: Dependency-Scanning.gitlab-ci.yml + +variables: + DS_DISABLE_DIND: "true" +``` + +This will create individual `<analyzer-name>-dependency_scanning` jobs for each analyzer that runs in your CI/CD pipeline. + ## Interacting with the vulnerabilities Once a vulnerability is found, you can interact with it. Read more on how to diff --git a/lib/gitlab/graphql/authorize/instrumentation.rb b/lib/gitlab/graphql/authorize/instrumentation.rb index 15ecc3b04f0..f9ff2b30eae 100644 --- a/lib/gitlab/graphql/authorize/instrumentation.rb +++ b/lib/gitlab/graphql/authorize/instrumentation.rb @@ -9,12 +9,16 @@ module Gitlab def instrument(_type, field) service = AuthorizeFieldService.new(field) - if service.authorizations? + if service.authorizations? && !resolver_skips_authorizations?(field) field.redefine { resolve(service.authorized_resolve) } else field end end + + def resolver_skips_authorizations?(field) + field.metadata[:resolver].try(:skip_authorizations?) + end end end end diff --git a/lib/gitlab/graphql/connections.rb b/lib/gitlab/graphql/connections.rb index 64f7a268b7e..38c7d98f37c 100644 --- a/lib/gitlab/graphql/connections.rb +++ b/lib/gitlab/graphql/connections.rb @@ -8,6 +8,10 @@ module Gitlab ActiveRecord::Relation, Gitlab::Graphql::Connections::Keyset::Connection ) + GraphQL::Relay::BaseConnection.register_connection_implementation( + Gitlab::Graphql::FilterableArray, + Gitlab::Graphql::Connections::FilterableArrayConnection + ) end end end diff --git a/lib/gitlab/graphql/connections/filterable_array_connection.rb b/lib/gitlab/graphql/connections/filterable_array_connection.rb new file mode 100644 index 00000000000..800f2c949c6 --- /dev/null +++ b/lib/gitlab/graphql/connections/filterable_array_connection.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Connections + # FilterableArrayConnection is useful especially for lazy-loaded values. + # It allows us to call a callback only on the slice of array being + # rendered in the "after loaded" phase. For example we can check + # permissions only on a small subset of items. + class FilterableArrayConnection < GraphQL::Relay::ArrayConnection + def paged_nodes + @filtered_nodes ||= nodes.filter_callback.call(super) + end + end + end + end +end diff --git a/lib/gitlab/graphql/filterable_array.rb b/lib/gitlab/graphql/filterable_array.rb new file mode 100644 index 00000000000..4909d291fd6 --- /dev/null +++ b/lib/gitlab/graphql/filterable_array.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + class FilterableArray < Array + attr_reader :filter_callback + + def initialize(filter_callback, *args) + super(args) + @filter_callback = filter_callback + end + end + end +end diff --git a/lib/gitlab/metrics/dashboard/stages/common_metrics_inserter.rb b/lib/gitlab/metrics/dashboard/stages/common_metrics_inserter.rb index 188912bedb4..62479ed6de4 100644 --- a/lib/gitlab/metrics/dashboard/stages/common_metrics_inserter.rb +++ b/lib/gitlab/metrics/dashboard/stages/common_metrics_inserter.rb @@ -9,7 +9,7 @@ module Gitlab # find a corresponding database record. If found, # includes the record's id in the dashboard config. def transform! - common_metrics = ::PrometheusMetric.common + common_metrics = ::PrometheusMetricsFinder.new(common: true).execute for_metrics do |metric| metric_record = common_metrics.find { |m| m.identifier == metric[:id] } diff --git a/lib/gitlab/metrics/dashboard/stages/project_metrics_inserter.rb b/lib/gitlab/metrics/dashboard/stages/project_metrics_inserter.rb index 643be309992..c0f67d445f8 100644 --- a/lib/gitlab/metrics/dashboard/stages/project_metrics_inserter.rb +++ b/lib/gitlab/metrics/dashboard/stages/project_metrics_inserter.rb @@ -9,7 +9,7 @@ module Gitlab # config. If there are no project-specific metrics, # this will have no effect. def transform! - project.prometheus_metrics.each do |project_metric| + PrometheusMetricsFinder.new(project: project).execute.each do |project_metric| group = find_or_create_panel_group(dashboard[:panel_groups], project_metric) panel = find_or_create_panel(group[:panels], project_metric) find_or_create_metric(panel[:metrics], project_metric) diff --git a/lib/gitlab/prometheus/metric_group.rb b/lib/gitlab/prometheus/metric_group.rb index caf0d453b6f..1b6f7282eb3 100644 --- a/lib/gitlab/prometheus/metric_group.rb +++ b/lib/gitlab/prometheus/metric_group.rb @@ -11,13 +11,15 @@ module Gitlab validates :name, :priority, :metrics, presence: true def self.common_metrics - all_groups = ::PrometheusMetric.common.group_by(&:group_title).map do |name, metrics| - MetricGroup.new( - name: name, - priority: metrics.map(&:priority).max, - metrics: metrics.map(&:to_query_metric) - ) - end + all_groups = ::PrometheusMetricsFinder.new(common: true).execute + .group_by(&:group_title) + .map do |name, metrics| + MetricGroup.new( + name: name, + priority: metrics.map(&:priority).max, + metrics: metrics.map(&:to_query_metric) + ) + end all_groups.sort_by(&:priority).reverse end diff --git a/lib/gitlab/prometheus/queries/knative_invocation_query.rb b/lib/gitlab/prometheus/queries/knative_invocation_query.rb index 2691abe46d6..8873608c411 100644 --- a/lib/gitlab/prometheus/queries/knative_invocation_query.rb +++ b/lib/gitlab/prometheus/queries/knative_invocation_query.rb @@ -7,11 +7,14 @@ module Gitlab include QueryAdditionalMetrics def query(serverless_function_id) - PrometheusMetric - .find_by_identifier(:system_metrics_knative_function_invocation_count) - .to_query_metric.tap do |q| - q.queries[0][:result] = run_query(q.queries[0][:query_range], context(serverless_function_id)) - end + PrometheusMetricsFinder + .new(identifier: :system_metrics_knative_function_invocation_count, common: true) + .execute + .first + .to_query_metric + .tap do |q| + q.queries[0][:result] = run_query(q.queries[0][:query_range], context(serverless_function_id)) + end end protected diff --git a/lib/gitlab/seeder.rb b/lib/gitlab/seeder.rb index 8e2f16271eb..f96346322db 100644 --- a/lib/gitlab/seeder.rb +++ b/lib/gitlab/seeder.rb @@ -14,7 +14,71 @@ end module Gitlab class Seeder + extend ActionView::Helpers::NumberHelper + + ESTIMATED_INSERT_PER_MINUTE = 2_000_000 + MASS_INSERT_ENV = 'MASS_INSERT' + + module ProjectSeed + extend ActiveSupport::Concern + + included do + scope :not_mass_generated, -> do + where.not("path LIKE '#{Gitlab::Seeder::Projects::MASS_INSERT_NAME_START}%'") + end + end + end + + module UserSeed + extend ActiveSupport::Concern + + included do + scope :not_mass_generated, -> do + where.not("username LIKE '#{Gitlab::Seeder::Users::MASS_INSERT_USERNAME_START}%'") + end + end + end + + def self.with_mass_insert(size, model) + humanized_model_name = model.is_a?(String) ? model : model.model_name.human.pluralize(size) + + if !ENV[MASS_INSERT_ENV] && !ENV['CI'] + puts "\nSkipping mass insertion for #{humanized_model_name}." + puts "Consider running the seed with #{MASS_INSERT_ENV}=1" + return + end + + humanized_size = number_with_delimiter(size) + estimative = estimated_time_message(size) + + puts "\nCreating #{humanized_size} #{humanized_model_name}." + puts estimative + + yield + + puts "\n#{number_with_delimiter(size)} #{humanized_model_name} created!" + end + + def self.estimated_time_message(size) + estimated_minutes = (size.to_f / ESTIMATED_INSERT_PER_MINUTE).round + humanized_minutes = 'minute'.pluralize(estimated_minutes) + + if estimated_minutes.zero? + "Rough estimated time: less than a minute ⏰" + else + "Rough estimated time: #{estimated_minutes} #{humanized_minutes} ⏰" + end + end + def self.quiet + # Disable database insertion logs so speed isn't limited by ability to print to console + old_logger = ActiveRecord::Base.logger + ActiveRecord::Base.logger = nil + + # Additional seed logic for models. + Project.include(ProjectSeed) + User.include(UserSeed) + mute_notifications mute_mailer @@ -23,6 +87,7 @@ module Gitlab yield SeedFu.quiet = false + ActiveRecord::Base.logger = old_logger puts "\nOK".color(:green) end diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake index b1db4dc94a6..0488f26318a 100644 --- a/lib/tasks/dev.rake +++ b/lib/tasks/dev.rake @@ -5,6 +5,10 @@ namespace :dev do task setup: :environment do ENV['force'] = 'yes' Rake::Task["gitlab:setup"].invoke + + # Make sure DB statistics are up to date. + ActiveRecord::Base.connection.execute('ANALYZE') + Rake::Task["gitlab:shell:setup"].invoke end diff --git a/lib/tasks/gitlab/seed.rake b/lib/tasks/gitlab/seed.rake index d76e38b73b5..d758280ba69 100644 --- a/lib/tasks/gitlab/seed.rake +++ b/lib/tasks/gitlab/seed.rake @@ -22,7 +22,7 @@ namespace :gitlab do [project] else - Project.find_each + Project.not_mass_generated.find_each end projects.each do |project| diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d1d10376352..e98f06864ce 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4386,6 +4386,9 @@ msgstr "" msgid "Compare changes with the merge request target branch" msgstr "" +msgid "Compare with previous version" +msgstr "" + msgid "CompareBranches|%{source_branch} and %{target_branch} are the same." msgstr "" @@ -5683,6 +5686,9 @@ msgstr "" msgid "Descending" msgstr "" +msgid "Describe the goal of the changes and what reviewers should be aware of." +msgstr "" + msgid "Description" msgstr "" @@ -10711,9 +10717,6 @@ msgstr "" msgid "MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}" msgstr "" -msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}" -msgstr "" - msgid "MergeRequest|Error dismissing suggestion popover. Please try again." msgstr "" @@ -10858,6 +10861,9 @@ msgstr "" msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response." msgstr "" +msgid "Metrics|Validating query" +msgstr "" + msgid "Metrics|Y-axis label" msgstr "" @@ -15926,6 +15932,9 @@ msgstr "" msgid "Something went wrong while fetching comments. Please try again." msgstr "" +msgid "Something went wrong while fetching description changes. Please try again." +msgstr "" + msgid "Something went wrong while fetching group member contributions" msgstr "" @@ -21193,10 +21202,5 @@ msgstr "" msgid "with %{additions} additions, %{deletions} deletions." msgstr "" -msgid "within %d minute " -msgid_plural "within %d minutes " -msgstr[0] "" -msgstr[1] "" - msgid "yaml invalid" msgstr "" diff --git a/qa/qa/page/admin/overview/users/show.rb b/qa/qa/page/admin/overview/users/show.rb index 11ea7bcabc8..f15ef0492fc 100644 --- a/qa/qa/page/admin/overview/users/show.rb +++ b/qa/qa/page/admin/overview/users/show.rb @@ -10,9 +10,19 @@ module QA element :impersonate_user_link end + view 'app/views/admin/users/show.html.haml' do + element :confirm_user_button + end + def click_impersonate_user click_element(:impersonate_user_link) end + + def confirm_user + accept_confirm do + click_element :confirm_user_button + end + end end end end diff --git a/qa/qa/page/group/show.rb b/qa/qa/page/group/show.rb index d4c4be0d6ca..e1f319da134 100644 --- a/qa/qa/page/group/show.rb +++ b/qa/qa/page/group/show.rb @@ -18,6 +18,10 @@ module QA element :no_result_text, 'No groups or projects matched your search' # rubocop:disable QA/ElementWithPattern end + view 'app/views/shared/members/_access_request_links.html.haml' do + element :leave_group_link + end + def click_subgroup(name) click_link name end @@ -42,6 +46,12 @@ module QA click_element :new_in_group_button end + def leave_group + accept_alert do + click_element :leave_group_link + end + end + private def select_kind(kind) diff --git a/qa/qa/resource/base.rb b/qa/qa/resource/base.rb index 86de421ba3f..ae20ca1a98e 100644 --- a/qa/qa/resource/base.rb +++ b/qa/qa/resource/base.rb @@ -64,12 +64,11 @@ module QA end def visit! - Runtime::Logger.debug("Visiting #{web_url}") + Runtime::Logger.debug(%Q[Visiting #{self.class.name} at "#{web_url}"]) if Runtime::Env.debug? Support::Retrier.retry_until do visit(web_url) - - wait { current_url == web_url } + wait { current_url.include?(URI.parse(web_url).path.split('/').last || web_url) } end end diff --git a/qa/qa/resource/members.rb b/qa/qa/resource/members.rb index d70a2907523..c738a91a77f 100644 --- a/qa/qa/resource/members.rb +++ b/qa/qa/resource/members.rb @@ -11,6 +11,10 @@ module QA post Runtime::API::Request.new(api_client, api_members_path).url, { user_id: user.id, access_level: access_level } end + def list_members + JSON.parse(get(Runtime::API::Request.new(api_client, api_members_path).url).body) + end + def api_members_path "#{api_get_path}/members" end diff --git a/qa/qa/resource/sandbox.rb b/qa/qa/resource/sandbox.rb index 6ee3dcf350f..6c87fcb377a 100644 --- a/qa/qa/resource/sandbox.rb +++ b/qa/qa/resource/sandbox.rb @@ -7,6 +7,8 @@ module QA # creating it if it doesn't yet exist. # class Sandbox < Base + include Members + attr_accessor :path attribute :id diff --git a/qa/qa/runtime/browser.rb b/qa/qa/runtime/browser.rb index c914526002c..7e45e5e86ea 100644 --- a/qa/qa/runtime/browser.rb +++ b/qa/qa/runtime/browser.rb @@ -57,13 +57,13 @@ module QA Capybara.register_driver QA::Runtime::Env.browser do |app| capabilities = Selenium::WebDriver::Remote::Capabilities.send(QA::Runtime::Env.browser, - # This enables access to logs with `page.driver.manage.get_log(:browser)` - loggingPrefs: { - browser: "ALL", - client: "ALL", - driver: "ALL", - server: "ALL" - }) + # This enables access to logs with `page.driver.manage.get_log(:browser)` + loggingPrefs: { + browser: "ALL", + client: "ALL", + driver: "ALL", + server: "ALL" + }) if QA::Runtime::Env.accept_insecure_certs? capabilities['acceptInsecureCerts'] = true diff --git a/qa/qa/runtime/feature.rb b/qa/qa/runtime/feature.rb index 75cb9eded55..8c19436ee12 100644 --- a/qa/qa/runtime/feature.rb +++ b/qa/qa/runtime/feature.rb @@ -19,6 +19,28 @@ module QA set_feature(key, false) end + def remove(key) + request = Runtime::API::Request.new(api_client, "/features/#{key}") + response = delete(request.url) + unless response.code == QA::Support::Api::HTTP_STATUS_NO_CONTENT + raise SetFeatureError, "Deleting feature flag #{key} failed with `#{response}`." + end + end + + def enable_and_verify(key) + Support::Retrier.retry_on_exception(sleep_interval: 2) do + enable(key) + + is_enabled = false + + QA::Support::Waiter.wait(interval: 1) do + is_enabled = enabled?(key) + end + + raise SetFeatureError, "#{key} was not enabled!" unless is_enabled + end + end + def enabled?(key) feature = JSON.parse(get_features).find { |flag| flag["name"] == key } feature && feature["state"] == "on" diff --git a/qa/qa/specs/features/browser_ui/1_manage/login/login_via_instance_wide_saml_sso_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/login/login_via_instance_wide_saml_sso_spec.rb index 101143399f6..ad67f02eaca 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/login/login_via_instance_wide_saml_sso_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/login/login_via_instance_wide_saml_sso_spec.rb @@ -8,7 +8,9 @@ module QA Page::Main::Login.perform(&:sign_in_with_saml) - Vendor::SAMLIdp::Page::Login.perform(&:login) + Vendor::SAMLIdp::Page::Login.perform do |login_page| + login_page.login('user1', 'user1pass') + end expect(page).to have_content('Welcome to GitLab') end diff --git a/qa/qa/vendor/saml_idp/page/login.rb b/qa/qa/vendor/saml_idp/page/login.rb index 1b8c926532a..9ebcabe15fc 100644 --- a/qa/qa/vendor/saml_idp/page/login.rb +++ b/qa/qa/vendor/saml_idp/page/login.rb @@ -7,18 +7,22 @@ module QA module SAMLIdp module Page class Login < Page::Base - def login - fill_in 'username', with: 'user1' - fill_in 'password', with: 'user1pass' + def login(username, password) + QA::Runtime::Logger.debug("Logging into SAMLIdp with username: #{username} and password:#{password}") if QA::Runtime::Env.debug? + + fill_in 'username', with: username + fill_in 'password', with: password click_on 'Login' end - def login_if_required - login if login_required? + def login_if_required(username, password) + login(username, password) if login_required? end def login_required? - page.has_text?('Enter your username and password') + login_required = page.has_text?('Enter your username and password') + QA::Runtime::Logger.debug("login_required: #{login_required}") if QA::Runtime::Env.debug? + login_required end end end diff --git a/qa/spec/spec_helper.rb b/qa/spec/spec_helper.rb index ed29bfa41dd..42f1e6f292a 100644 --- a/qa/spec/spec_helper.rb +++ b/qa/spec/spec_helper.rb @@ -20,7 +20,7 @@ RSpec.configure do |config| QA::Specs::Helpers::Quarantine.configure_rspec config.before do |example| - QA::Runtime::Logger.debug("Starting test: #{example.full_description}") if QA::Runtime::Env.debug? + QA::Runtime::Logger.debug("\nStarting test: #{example.full_description}\n") if QA::Runtime::Env.debug? end config.after(:context) do diff --git a/spec/features/merge_request/user_creates_merge_request_spec.rb b/spec/features/merge_request/user_creates_merge_request_spec.rb index afb792c2ab9..67f6d8ebe32 100644 --- a/spec/features/merge_request/user_creates_merge_request_spec.rb +++ b/spec/features/merge_request/user_creates_merge_request_spec.rb @@ -25,6 +25,11 @@ describe "User creates a merge request", :js do click_button("Compare branches") + page.within('.merge-request-form') do + expect(page.find('#merge_request_title')['placeholder']).to eq 'Title' + expect(page.find('#merge_request_description')['placeholder']).to eq 'Describe the goal of the changes and what reviewers should be aware of.' + end + fill_in("Title", with: title) click_button("Submit merge request") diff --git a/spec/finders/prometheus_metrics_finder_spec.rb b/spec/finders/prometheus_metrics_finder_spec.rb new file mode 100644 index 00000000000..41b2e700e1e --- /dev/null +++ b/spec/finders/prometheus_metrics_finder_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe PrometheusMetricsFinder do + describe '#execute' do + let(:finder) { described_class.new(params) } + let(:params) { {} } + + subject { finder.execute } + + context 'with params' do + let_it_be(:project) { create(:project) } + let_it_be(:project_metric) { create(:prometheus_metric, project: project) } + let_it_be(:common_metric) { create(:prometheus_metric, :common) } + let_it_be(:unique_metric) do + create( + :prometheus_metric, + :common, + title: 'Unique title', + y_label: 'Unique y_label', + group: :kubernetes, + identifier: 'identifier', + created_at: 5.minutes.ago + ) + end + + context 'with appropriate indexes' do + before do + allow_any_instance_of(described_class).to receive(:appropriate_index?).and_return(true) + end + + context 'with project' do + let(:params) { { project: project } } + + it { is_expected.to eq([project_metric]) } + end + + context 'with group' do + let(:params) { { group: project_metric.group } } + + it { is_expected.to contain_exactly(common_metric, project_metric) } + end + + context 'with title' do + let(:params) { { title: project_metric.title } } + + it { is_expected.to contain_exactly(project_metric, common_metric) } + end + + context 'with y_label' do + let(:params) { { y_label: project_metric.y_label } } + + it { is_expected.to contain_exactly(project_metric, common_metric) } + end + + context 'with common' do + let(:params) { { common: true } } + + it { is_expected.to contain_exactly(common_metric, unique_metric) } + end + + context 'with ordered' do + let(:params) { { ordered: true } } + + it { is_expected.to eq([unique_metric, project_metric, common_metric]) } + end + + context 'with indentifier' do + let(:params) { { identifier: unique_metric.identifier } } + + it 'raises an error' do + expect { subject }.to raise_error( + ArgumentError, + ':identifier must be scoped to a :project or :common' + ) + end + + context 'with common' do + let(:params) { { identifier: unique_metric.identifier, common: true } } + + it { is_expected.to contain_exactly(unique_metric) } + end + + context 'with id' do + let(:params) { { id: 14, identifier: 'string' } } + + it 'raises an error' do + expect { subject }.to raise_error( + ArgumentError, + 'Only one of :identifier, :id is permitted' + ) + end + end + end + + context 'with id' do + let(:params) { { id: common_metric.id } } + + it { is_expected.to contain_exactly(common_metric) } + end + + context 'with multiple params' do + let(:params) do + { + group: project_metric.group, + title: project_metric.title, + y_label: project_metric.y_label, + common: true, + ordered: true + } + end + + it { is_expected.to contain_exactly(common_metric) } + end + end + + context 'without an appropriate index' do + let(:params) do + { + title: project_metric.title, + ordered: true + } + end + + it 'raises an error' do + expect { subject }.to raise_error( + ArgumentError, + 'An index should exist for params: [:title]' + ) + end + end + end + + context 'without params' do + it 'raises an error' do + expect { subject }.to raise_error( + ArgumentError, + 'Please provide one or more of: [:project, :group, :title, :y_label, :identifier, :id, :common, :ordered]' + ) + end + end + end +end diff --git a/spec/frontend/helpers/monitor_helper_spec.js b/spec/frontend/helpers/monitor_helper_spec.js index c36b603b251..0798ca580e2 100644 --- a/spec/frontend/helpers/monitor_helper_spec.js +++ b/spec/frontend/helpers/monitor_helper_spec.js @@ -81,6 +81,17 @@ describe('monitor helper', () => { expect(result.name).toEqual('brpop, brpop'); }); + it('supports hyphenated template variables', () => { + const config = { ...defaultConfig, name: 'expired - {{ test-attribute }}' }; + + const [result] = monitorHelper.makeDataSeries( + [{ metric: { 'test-attribute': 'test-attribute-value' }, values: series }], + config, + ); + + expect(result.name).toEqual('expired - test-attribute-value'); + }); + it('updates multiple series names from templates', () => { const config = { ...defaultConfig, diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js index dc914ce8355..01cb70d395c 100644 --- a/spec/frontend/notes/mock_data.js +++ b/spec/frontend/notes/mock_data.js @@ -1094,8 +1094,9 @@ export const collapsedSystemNotes = [ noteable_type: 'Issue', resolvable: false, noteable_iid: 12, + start_description_version_id: undefined, note: 'changed the description', - note_html: ' <p dir="auto">changed the description 2 times within 1 minute </p>', + note_html: '<p dir="auto">changed the description</p>', current_user: { can_edit: false, can_award_emoji: true }, resolved: false, resolved_by: null, @@ -1106,7 +1107,6 @@ export const collapsedSystemNotes = [ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_905&user_id=1', human_access: 'Owner', path: '/gitlab-org/gitlab-shell/notes/905', - times_updated: 2, }, ], individual_note: true, diff --git a/spec/frontend/registry/components/collapsible_container_spec.js b/spec/frontend/registry/components/collapsible_container_spec.js index f93ebab1a4d..d035055afd3 100644 --- a/spec/frontend/registry/components/collapsible_container_spec.js +++ b/spec/frontend/registry/components/collapsible_container_spec.js @@ -1,10 +1,11 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; -import collapsibleComponent from '~/registry/components/collapsible_container.vue'; -import { repoPropsData } from '../mock_data'; import createFlash from '~/flash'; +import Tracking from '~/tracking'; +import collapsibleComponent from '~/registry/components/collapsible_container.vue'; import * as getters from '~/registry/stores/getters'; +import { repoPropsData } from '../mock_data'; jest.mock('~/flash.js'); @@ -16,9 +17,10 @@ describe('collapsible registry container', () => { let wrapper; let store; - const findDeleteBtn = w => w.find('.js-remove-repo'); - const findContainerImageTags = w => w.find('.container-image-tags'); - const findToggleRepos = w => w.findAll('.js-toggle-repo'); + const findDeleteBtn = (w = wrapper) => w.find('.js-remove-repo'); + const findContainerImageTags = (w = wrapper) => w.find('.container-image-tags'); + const findToggleRepos = (w = wrapper) => w.findAll('.js-toggle-repo'); + const findDeleteModal = (w = wrapper) => w.find({ ref: 'deleteModal' }); const mountWithStore = config => mount(collapsibleComponent, { ...config, store, localVue }); @@ -124,4 +126,45 @@ describe('collapsible registry container', () => { expect(deleteBtn.exists()).toBe(false); }); }); + + describe('tracking', () => { + const category = 'mock_page'; + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + wrapper.vm.deleteItem = jest.fn().mockResolvedValue(); + wrapper.vm.fetchRepos = jest.fn(); + wrapper.setData({ + tracking: { + ...wrapper.vm.tracking, + category, + }, + }); + }); + + it('send an event when delete button is clicked', () => { + const deleteBtn = findDeleteBtn(); + deleteBtn.trigger('click'); + expect(Tracking.event).toHaveBeenCalledWith(category, 'click_button', { + label: 'registry_repository_delete', + category, + }); + }); + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + expect(Tracking.event).toHaveBeenCalledWith(category, 'cancel_delete', { + label: 'registry_repository_delete', + category, + }); + }); + it('send an event when confirm is clicked on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('ok'); + + expect(Tracking.event).toHaveBeenCalledWith(category, 'confirm_delete', { + label: 'registry_repository_delete', + category, + }); + }); + }); }); diff --git a/spec/frontend/registry/components/table_registry_spec.js b/spec/frontend/registry/components/table_registry_spec.js index 7cb7c012d9d..ab88caf44e1 100644 --- a/spec/frontend/registry/components/table_registry_spec.js +++ b/spec/frontend/registry/components/table_registry_spec.js @@ -1,10 +1,14 @@ import Vue from 'vue'; import Vuex from 'vuex'; -import tableRegistry from '~/registry/components/table_registry.vue'; import { mount, createLocalVue } from '@vue/test-utils'; +import createFlash from '~/flash'; +import Tracking from '~/tracking'; +import tableRegistry from '~/registry/components/table_registry.vue'; import { repoPropsData } from '../mock_data'; import * as getters from '~/registry/stores/getters'; +jest.mock('~/flash'); + const [firstImage, secondImage] = repoPropsData.list; const localVue = createLocalVue(); @@ -15,11 +19,12 @@ describe('table registry', () => { let wrapper; let store; - const findSelectAllCheckbox = w => w.find('.js-select-all-checkbox > input'); - const findSelectCheckboxes = w => w.findAll('.js-select-checkbox > input'); - const findDeleteButton = w => w.find('.js-delete-registry'); - const findDeleteButtonsRow = w => w.findAll('.js-delete-registry-row'); - const findPagination = w => w.find('.js-registry-pagination'); + const findSelectAllCheckbox = (w = wrapper) => w.find('.js-select-all-checkbox > input'); + const findSelectCheckboxes = (w = wrapper) => w.findAll('.js-select-checkbox > input'); + const findDeleteButton = (w = wrapper) => w.find({ ref: 'bulkDeleteButton' }); + const findDeleteButtonsRow = (w = wrapper) => w.findAll('.js-delete-registry-row'); + const findPagination = (w = wrapper) => w.find('.js-registry-pagination'); + const findDeleteModal = (w = wrapper) => w.find({ ref: 'deleteModal' }); const bulkDeletePath = 'path'; const mountWithStore = config => mount(tableRegistry, { ...config, store, localVue }); @@ -139,7 +144,7 @@ describe('table registry', () => { }, }); wrapper.vm.handleMultipleDelete(); - expect(wrapper.vm.showError).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); }); }); @@ -169,6 +174,27 @@ describe('table registry', () => { }); }); + describe('modal event handlers', () => { + beforeEach(() => { + wrapper.vm.handleSingleDelete = jest.fn(); + wrapper.vm.handleMultipleDelete = jest.fn(); + }); + it('on ok when one item is selected should call singleDelete', () => { + wrapper.setData({ itemsToBeDeleted: [0] }); + wrapper.vm.onDeletionConfirmed(); + + expect(wrapper.vm.handleSingleDelete).toHaveBeenCalledWith(repoPropsData.list[0]); + expect(wrapper.vm.handleMultipleDelete).not.toHaveBeenCalled(); + }); + it('on ok when multiple items are selected should call muultiDelete', () => { + wrapper.setData({ itemsToBeDeleted: [0, 1, 2] }); + wrapper.vm.onDeletionConfirmed(); + + expect(wrapper.vm.handleMultipleDelete).toHaveBeenCalled(); + expect(wrapper.vm.handleSingleDelete).not.toHaveBeenCalled(); + }); + }); + describe('pagination', () => { const repo = { repoPropsData, @@ -265,4 +291,83 @@ describe('table registry', () => { expect(deleteBtns.length).toBe(0); }); }); + + describe('event tracking', () => { + const mockPageName = 'mock_page'; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + wrapper.vm.handleSingleDelete = jest.fn(); + wrapper.vm.handleMultipleDelete = jest.fn(); + document.body.dataset.page = mockPageName; + }); + + afterEach(() => { + document.body.dataset.page = null; + }); + + describe('single tag delete', () => { + beforeEach(() => { + wrapper.setData({ itemsToBeDeleted: [0] }); + }); + + it('send an event when delete button is clicked', () => { + const deleteBtn = findDeleteButtonsRow(); + deleteBtn.at(0).trigger('click'); + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'click_button', { + label: 'registry_tag_delete', + property: 'foo', + }); + }); + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'cancel_delete', { + label: 'registry_tag_delete', + property: 'foo', + }); + }); + it('send an event when confirm is clicked on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('ok'); + + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'confirm_delete', { + label: 'registry_tag_delete', + property: 'foo', + }); + }); + }); + describe('bulk tag delete', () => { + beforeEach(() => { + const items = [0, 1, 2]; + wrapper.setData({ itemsToBeDeleted: items, selectedItems: items }); + }); + + it('send an event when delete button is clicked', () => { + const deleteBtn = findDeleteButton(); + deleteBtn.vm.$emit('click'); + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'click_button', { + label: 'bulk_registry_tag_delete', + property: 'foo', + }); + }); + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'cancel_delete', { + label: 'bulk_registry_tag_delete', + property: 'foo', + }); + }); + it('send an event when confirm is clicked on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('ok'); + + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'confirm_delete', { + label: 'bulk_registry_tag_delete', + property: 'foo', + }); + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/notes/system_note_spec.js b/spec/frontend/vue_shared/components/notes/system_note_spec.js index a65e3eb294a..c2e8359f78d 100644 --- a/spec/frontend/vue_shared/components/notes/system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js @@ -57,7 +57,7 @@ describe('system note component', () => { // we need to strip them because they break layout of commit lists in system notes: // https://gitlab.com/gitlab-org/gitlab-foss/uploads/b07a10670919254f0220d3ff5c1aa110/jqzI.png it('removes wrapping paragraph from note HTML', () => { - expect(vm.$el.querySelector('.system-note-message').innerHTML).toEqual('<span>closed</span>'); + expect(vm.$el.querySelector('.system-note-message').innerHTML).toContain('<span>closed</span>'); }); it('should initMRPopovers onMount', () => { diff --git a/spec/javascripts/bootstrap_jquery_spec.js b/spec/javascripts/bootstrap_jquery_spec.js index 35340a3bc42..6957cf40301 100644 --- a/spec/javascripts/bootstrap_jquery_spec.js +++ b/spec/javascripts/bootstrap_jquery_spec.js @@ -1,5 +1,3 @@ -/* eslint-disable no-var */ - import $ from 'jquery'; import '~/commons/bootstrap'; @@ -10,15 +8,13 @@ describe('Bootstrap jQuery extensions', function() { }); it('adds the disabled attribute', function() { - var $input; - $input = $('input').first(); + const $input = $('input').first(); $input.disable(); expect($input).toHaveAttr('disabled', 'disabled'); }); return it('adds the disabled class', function() { - var $input; - $input = $('input').first(); + const $input = $('input').first(); $input.disable(); expect($input).toHaveClass('disabled'); @@ -30,15 +26,13 @@ describe('Bootstrap jQuery extensions', function() { }); it('removes the disabled attribute', function() { - var $input; - $input = $('input').first(); + const $input = $('input').first(); $input.enable(); expect($input).not.toHaveAttr('disabled'); }); return it('removes the disabled class', function() { - var $input; - $input = $('input').first(); + const $input = $('input').first(); $input.enable(); expect($input).not.toHaveClass('disabled'); diff --git a/spec/javascripts/monitoring/components/dashboard_spec.js b/spec/javascripts/monitoring/components/dashboard_spec.js index 674db7c7afb..0f20171726c 100644 --- a/spec/javascripts/monitoring/components/dashboard_spec.js +++ b/spec/javascripts/monitoring/components/dashboard_spec.js @@ -122,6 +122,32 @@ describe('Dashboard', () => { }); }); + describe('cluster health', () => { + let wrapper; + + beforeEach(done => { + wrapper = shallowMount(DashboardComponent, { + localVue, + sync: false, + propsData: { ...propsData, hasMetrics: true }, + store, + }); + + // all_dashboards is not defined in health dashboards + wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined); + wrapper.vm.$nextTick(done); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders correctly', () => { + expect(wrapper.isVueInstance()).toBe(true); + expect(wrapper.exists()).toBe(true); + }); + }); + describe('requests information to the server', () => { let spy; beforeEach(() => { diff --git a/spec/javascripts/monitoring/store/mutations_spec.js b/spec/javascripts/monitoring/store/mutations_spec.js index 498c1a9e9b1..91948b83eec 100644 --- a/spec/javascripts/monitoring/store/mutations_spec.js +++ b/spec/javascripts/monitoring/store/mutations_spec.js @@ -144,7 +144,19 @@ describe('Monitoring mutations', () => { }); describe('SET_ALL_DASHBOARDS', () => { - it('stores the dashboards loaded from the git repository', () => { + it('stores `undefined` dashboards as an empty array', () => { + mutations[types.SET_ALL_DASHBOARDS](stateCopy, undefined); + + expect(stateCopy.allDashboards).toEqual([]); + }); + + it('stores `null` dashboards as an empty array', () => { + mutations[types.SET_ALL_DASHBOARDS](stateCopy, null); + + expect(stateCopy.allDashboards).toEqual([]); + }); + + it('stores dashboards loaded from the git repository', () => { mutations[types.SET_ALL_DASHBOARDS](stateCopy, dashboardGitResponse); expect(stateCopy.allDashboards).toEqual(dashboardGitResponse); diff --git a/spec/javascripts/notes/stores/collapse_utils_spec.js b/spec/javascripts/notes/stores/collapse_utils_spec.js index 8ede9319088..d3019f4b9a4 100644 --- a/spec/javascripts/notes/stores/collapse_utils_spec.js +++ b/spec/javascripts/notes/stores/collapse_utils_spec.js @@ -1,6 +1,5 @@ import { isDescriptionSystemNote, - changeDescriptionNote, getTimeDifferenceMinutes, collapseSystemNotes, } from '~/notes/stores/collapse_utils'; @@ -24,15 +23,6 @@ describe('Collapse utils', () => { ); }); - it('changes the description to contain the number of changed times', () => { - const changedNote = changeDescriptionNote(mockSystemNote, 3, 5); - - expect(changedNote.times_updated).toEqual(3); - expect(changedNote.note_html.trim()).toContain( - '<p dir="auto">changed the description 3 times within 5 minutes </p>', - ); - }); - it('gets the time difference between two notes', () => { const anotherSystemNote = { created_at: '2018-05-14T21:33:00.000Z', diff --git a/spec/lib/gitlab/graphql/connections/filterable_array_connection_spec.rb b/spec/lib/gitlab/graphql/connections/filterable_array_connection_spec.rb new file mode 100644 index 00000000000..1fda84f777e --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/filterable_array_connection_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::FilterableArrayConnection do + let(:callback) { proc { |nodes| nodes } } + let(:all_nodes) { Gitlab::Graphql::FilterableArray.new(callback, 1, 2, 3, 4, 5) } + let(:arguments) { {} } + subject(:connection) do + described_class.new(all_nodes, arguments, max_page_size: 3) + end + + describe '#paged_nodes' do + let(:paged_nodes) { subject.paged_nodes } + + it_behaves_like "connection with paged nodes" + + context 'when callback filters some nodes' do + let(:callback) { proc { |nodes| nodes[1..-1] } } + + it 'does not return filtered elements' do + expect(subject.paged_nodes).to contain_exactly(all_nodes[1], all_nodes[2]) + end + end + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb index ba1addadb5a..9dda2a41ec6 100644 --- a/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb +++ b/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb @@ -240,38 +240,16 @@ describe Gitlab::Graphql::Connections::Keyset::Connection do end describe '#paged_nodes' do - let!(:projects) { create_list(:project, 5) } + let_it_be(:all_nodes) { create_list(:project, 5) } + let(:paged_nodes) { subject.paged_nodes } - it 'returns the collection limited to max page size' do - expect(subject.paged_nodes.size).to eq(3) - end - - it 'is a loaded memoized array' do - expect(subject.paged_nodes).to be_an(Array) - expect(subject.paged_nodes.object_id).to eq(subject.paged_nodes.object_id) - end - - context 'when `first` is passed' do - let(:arguments) { { first: 2 } } - - it 'returns only the first elements' do - expect(subject.paged_nodes).to contain_exactly(projects.first, projects.second) - end - end - - context 'when `last` is passed' do - let(:arguments) { { last: 2 } } - - it 'returns only the last elements' do - expect(subject.paged_nodes).to contain_exactly(projects[3], projects[4]) - end - end + it_behaves_like "connection with paged nodes" context 'when both are passed' do let(:arguments) { { first: 2, last: 2 } } it 'raises an error' do - expect { subject.paged_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + expect { paged_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) end end diff --git a/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb b/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb index 7f6283715f2..6361893c53c 100644 --- a/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb +++ b/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb @@ -13,14 +13,19 @@ describe Gitlab::Prometheus::Queries::KnativeInvocationQuery do context 'verify queries' do before do - allow(PrometheusMetric).to receive(:find_by_identifier).and_return(create(:prometheus_metric, query: prometheus_istio_query('test-name', 'test-ns'))) - allow(client).to receive(:query_range) + create(:prometheus_metric, + :common, + identifier: :system_metrics_knative_function_invocation_count, + query: 'sum(ceil(rate(istio_requests_total{destination_service_namespace="%{kube_namespace}", destination_app=~"%{function_name}.*"}[1m])*60))') end it 'has the query, but no data' do - results = subject.query(serverless_func.id) + expect(client).to receive(:query_range).with( + 'sum(ceil(rate(istio_requests_total{destination_service_namespace="test-ns", destination_app=~"test-name.*"}[1m])*60))', + hash_including(:start, :stop) + ) - expect(results.queries[0][:query_range]).to eql('floor(sum(rate(istio_revision_request_count{destination_configuration="test-name", destination_namespace="test-ns"}[1m])*30))') + subject.query(serverless_func.id) end end end diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index 6fb1d279456..80a3f7df05f 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -37,9 +37,12 @@ module GraphqlHelpers # BatchLoader::GraphQL returns a wrapper, so we need to :sync in order # to get the actual values def batch_sync(max_queries: nil, &blk) - result = batch(max_queries: nil, &blk) + wrapper = proc do + lazy_vals = yield + lazy_vals.is_a?(Array) ? lazy_vals.map(&:sync) : lazy_vals&.sync + end - result.is_a?(Array) ? result.map(&:sync) : result&.sync + batch(max_queries: max_queries, &wrapper) end def graphql_query_for(name, attributes = {}, fields = nil) @@ -157,7 +160,13 @@ module GraphqlHelpers def attributes_to_graphql(attributes) attributes.map do |name, value| - "#{GraphqlHelpers.fieldnamerize(name.to_s)}: \"#{value}\"" + value_str = if value.is_a?(Array) + '["' + value.join('","') + '"]' + else + "\"#{value}\"" + end + + "#{GraphqlHelpers.fieldnamerize(name.to_s)}: #{value_str}" end.join(", ") end @@ -282,6 +291,12 @@ module GraphqlHelpers def allow_high_graphql_recursion allow_any_instance_of(Gitlab::Graphql::QueryAnalyzers::RecursionAnalyzer).to receive(:recursion_threshold).and_return 1000 end + + def node_array(data, extract_attribute = nil) + data.map do |item| + extract_attribute ? item['node'][extract_attribute] : item['node'] + end + end end # This warms our schema, doing this as part of loading the helpers to avoid diff --git a/spec/support/shared_examples/graphql/connection_paged_nodes.rb b/spec/support/shared_examples/graphql/connection_paged_nodes.rb new file mode 100644 index 00000000000..830d2d2d4b1 --- /dev/null +++ b/spec/support/shared_examples/graphql/connection_paged_nodes.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'connection with paged nodes' do + it 'returns the collection limited to max page size' do + expect(paged_nodes.size).to eq(3) + end + + it 'is a loaded memoized array' do + expect(paged_nodes).to be_an(Array) + expect(paged_nodes.object_id).to eq(paged_nodes.object_id) + end + + context 'when `first` is passed' do + let(:arguments) { { first: 2 } } + + it 'returns only the first elements' do + expect(paged_nodes).to contain_exactly(all_nodes.first, all_nodes.second) + end + end + + context 'when `last` is passed' do + let(:arguments) { { last: 2 } } + + it 'returns only the last elements' do + expect(paged_nodes).to contain_exactly(all_nodes[3], all_nodes[4]) + end + end +end |