Commit 3fc9a8e6 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent e24153b0
...@@ -82,3 +82,4 @@ jsdoc/ ...@@ -82,3 +82,4 @@ jsdoc/
**/tmp/rubocop_cache/** **/tmp/rubocop_cache/**
.overcommit.yml .overcommit.yml
.projections.json .projections.json
/qa/.rakeTasks
...@@ -159,6 +159,7 @@ gem 'icalendar' ...@@ -159,6 +159,7 @@ gem 'icalendar'
# Diffs # Diffs
gem 'diffy', '~> 3.1.0' gem 'diffy', '~> 3.1.0'
gem 'diff_match_patch', '~> 0.1.0'
# Application server # Application server
gem 'rack', '~> 2.0.7' gem 'rack', '~> 2.0.7'
......
...@@ -224,6 +224,7 @@ GEM ...@@ -224,6 +224,7 @@ GEM
railties railties
rotp (~> 2.0) rotp (~> 2.0)
diff-lcs (1.3) diff-lcs (1.3)
diff_match_patch (0.1.0)
diffy (3.1.0) diffy (3.1.0)
discordrb-webhooks-blackst0ne (3.3.0) discordrb-webhooks-blackst0ne (3.3.0)
rest-client (~> 2.0) rest-client (~> 2.0)
...@@ -1133,6 +1134,7 @@ DEPENDENCIES ...@@ -1133,6 +1134,7 @@ DEPENDENCIES
device_detector device_detector
devise (~> 4.6) devise (~> 4.6)
devise-two-factor (~> 3.0.0) devise-two-factor (~> 3.0.0)
diff_match_patch (~> 0.1.0)
diffy (~> 3.1.0) diffy (~> 3.1.0)
discordrb-webhooks-blackst0ne (~> 3.3) discordrb-webhooks-blackst0ne (~> 3.3)
doorkeeper (~> 4.3) doorkeeper (~> 4.3)
......
/* eslint-disable import/prefer-default-export */
import _ from 'underscore';
/** /**
* @param {Array} queryResults - Array of Result objects * @param {Array} queryResults - Array of Result objects
* @param {Object} defaultConfig - Default chart config values (e.g. lineStyle, name) * @param {Object} defaultConfig - Default chart config values (e.g. lineStyle, name)
* @returns {Array} The formatted values * @returns {Array} The formatted values
*/ */
// eslint-disable-next-line import/prefer-default-export
export const makeDataSeries = (queryResults, defaultConfig) => export const makeDataSeries = (queryResults, defaultConfig) =>
queryResults queryResults
.map(result => { .map(result => {
...@@ -19,10 +17,13 @@ export const makeDataSeries = (queryResults, defaultConfig) => ...@@ -19,10 +17,13 @@ export const makeDataSeries = (queryResults, defaultConfig) =>
if (name) { if (name) {
series.name = `${defaultConfig.name}: ${name}`; series.name = `${defaultConfig.name}: ${name}`;
} else { } else {
const template = _.template(defaultConfig.name, { series.name = defaultConfig.name;
interpolate: /\{\{(.+?)\}\}/g, 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 }; return { ...defaultConfig, ...series };
......
/* eslint-disable no-var, one-var, consistent-return */ /* eslint-disable consistent-return */
import $ from 'jquery'; import $ from 'jquery';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
...@@ -91,18 +91,17 @@ export default class Issue { ...@@ -91,18 +91,17 @@ export default class Issue {
'click', 'click',
'.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen',
e => { e => {
var $button, shouldSubmit, url;
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
$button = $(e.currentTarget); const $button = $(e.currentTarget);
shouldSubmit = $button.hasClass('btn-comment'); const shouldSubmit = $button.hasClass('btn-comment');
if (shouldSubmit) { if (shouldSubmit) {
Issue.submitNoteForm($button.closest('form')); Issue.submitNoteForm($button.closest('form'));
} }
this.disableCloseReopenButton($button); this.disableCloseReopenButton($button);
url = $button.attr('href'); const url = $button.attr('href');
return axios return axios
.put(url) .put(url)
.then(({ data }) => { .then(({ data }) => {
...@@ -139,16 +138,14 @@ export default class Issue { ...@@ -139,16 +138,14 @@ export default class Issue {
} }
static submitNoteForm(form) { static submitNoteForm(form) {
var noteText; const noteText = form.find('textarea.js-note-text').val();
noteText = form.find('textarea.js-note-text').val();
if (noteText && noteText.trim().length > 0) { if (noteText && noteText.trim().length > 0) {
return form.submit(); return form.submit();
} }
} }
static initRelatedBranches() { static initRelatedBranches() {
var $container; const $container = $('#related-branches');
$container = $('#related-branches');
return axios return axios
.get($container.data('url')) .get($container.data('url'))
.then(({ data }) => { .then(({ data }) => {
......
This diff is collapsed.
...@@ -84,7 +84,10 @@ export const fetchDashboard = ({ state, dispatch }, params) => { ...@@ -84,7 +84,10 @@ export const fetchDashboard = ({ state, dispatch }, params) => {
return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) return backOffRequest(() => axios.get(state.dashboardEndpoint, { params }))
.then(resp => resp.data) .then(resp => resp.data)
.then(response => { .then(response => {
dispatch('receiveMetricsDashboardSuccess', { response, params }); dispatch('receiveMetricsDashboardSuccess', {
response,
params,
});
}) })
.catch(error => { .catch(error => {
dispatch('receiveMetricsDashboardFailure', error); dispatch('receiveMetricsDashboardFailure', error);
......
...@@ -94,7 +94,7 @@ export default { ...@@ -94,7 +94,7 @@ export default {
state.emptyState = 'noData'; state.emptyState = 'noData';
}, },
[types.SET_ALL_DASHBOARDS](state, dashboards) { [types.SET_ALL_DASHBOARDS](state, dashboards) {
state.allDashboards = dashboards; state.allDashboards = dashboards || [];
}, },
[types.SET_SHOW_ERROR_BANNER](state, enabled) { [types.SET_SHOW_ERROR_BANNER](state, enabled) {
state.showErrorBanner = enabled; state.showErrorBanner = enabled;
......
...@@ -101,6 +101,7 @@ export default { ...@@ -101,6 +101,7 @@ export default {
<time-ago-tooltip :time="createdAt" tooltip-placement="bottom" /> <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" />
</a> </a>
</template> </template>
<slot name="extra-controls"></slot>
<i <i
class="fa fa-spinner fa-spin editing-spinner" class="fa fa-spinner fa-spin editing-spinner"
:aria-label="__('Comment is being updated')" :aria-label="__('Comment is being updated')"
......
// Placeholder for GitLab FOSS
// Actual implementation: ee/app/assets/javascripts/notes/mixins/description_version_history.js
export default {
computed: {
canSeeDescriptionVersion() {},
shouldShowDescriptionVersion() {},
descriptionVersionToggleIcon() {},
},
methods: {
toggleDescriptionVersion() {},
},
};
...@@ -12,6 +12,7 @@ import service from '../services/notes_service'; ...@@ -12,6 +12,7 @@ import service from '../services/notes_service';
import loadAwardsHandler from '../../awards_handler'; import loadAwardsHandler from '../../awards_handler';
import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; 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 mrWidgetEventHub from '../../vue_merge_request_widget/event_hub';
import { __ } from '~/locale'; import { __ } from '~/locale';
import Api from '~/api'; import Api from '~/api';
...@@ -475,5 +476,20 @@ export const convertToDiscussion = ({ commit }, noteId) => ...@@ -475,5 +476,20 @@ export const convertToDiscussion = ({ commit }, noteId) =>
export const removeConvertedDiscussion = ({ commit }, noteId) => export const removeConvertedDiscussion = ({ commit }, noteId) =>
commit(types.REMOVE_CONVERTED_DISCUSSION, 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 // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
import { n__, s__, sprintf } from '~/locale';
import { DESCRIPTION_TYPE } from '../constants'; 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 * Checks the time difference between two notes from their 'created_at' dates
* returns an integer * returns an integer
*/ */
export const getTimeDifferenceMinutes = (noteBeggining, noteEnd) => { export const getTimeDifferenceMinutes = (noteBeggining, noteEnd) => {
const descriptionNoteBegin = new Date(noteBeggining.created_at); const descriptionNoteBegin = new Date(noteBeggining.created_at);
const descriptionNoteEnd = new Date(noteEnd.created_at); const descriptionNoteEnd = new Date(noteEnd.created_at);
...@@ -57,7 +32,6 @@ export const isDescriptionSystemNote = note => note.system && note.note === DESC ...@@ -57,7 +32,6 @@ export const isDescriptionSystemNote = note => note.system && note.note === DESC
export const collapseSystemNotes = notes => { export const collapseSystemNotes = notes => {
let lastDescriptionSystemNote = null; let lastDescriptionSystemNote = null;
let lastDescriptionSystemNoteIndex = -1; let lastDescriptionSystemNoteIndex = -1;
let descriptionChangedTimes = 1;
return notes.slice(0).reduce((acc, currentNote) => { return notes.slice(0).reduce((acc, currentNote) => {
const note = currentNote.notes[0]; const note = currentNote.notes[0];
...@@ -70,32 +44,24 @@ export const collapseSystemNotes = notes => { ...@@ -70,32 +44,24 @@ export const collapseSystemNotes = notes => {
} else if (lastDescriptionSystemNote) { } else if (lastDescriptionSystemNote) {
const timeDifferenceMinutes = getTimeDifferenceMinutes(lastDescriptionSystemNote, note); const timeDifferenceMinutes = getTimeDifferenceMinutes(lastDescriptionSystemNote, note);
// are they less than 10 minutes apart? // are they less than 10 minutes apart from the same user?
if (timeDifferenceMinutes > 10) { if (timeDifferenceMinutes > 10 || note.author.id !== lastDescriptionSystemNote.author.id) {
// reset counter
descriptionChangedTimes = 1;
// update the previous system note // update the previous system note
lastDescriptionSystemNote = note; lastDescriptionSystemNote = note;
lastDescriptionSystemNoteIndex = acc.length; lastDescriptionSystemNoteIndex = acc.length;
} else { } else {
// increase counter // set the first version to fetch grouped system note versions
descriptionChangedTimes += 1; note.start_description_version_id = lastDescriptionSystemNote.description_version_id;
// delete the previous one // delete the previous one
acc.splice(lastDescriptionSystemNoteIndex, 1); 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 // update the previous system note index
lastDescriptionSystemNoteIndex = acc.length; lastDescriptionSystemNoteIndex = acc.length;
} }
} }
} }
acc.push(currentNote); acc.push(currentNote);
return acc; return acc;
}, []); }, []);
......
...@@ -8,12 +8,13 @@ import { ...@@ -8,12 +8,13 @@ import {
GlModalDirective, GlModalDirective,
GlEmptyState, GlEmptyState,
} from '@gitlab/ui'; } from '@gitlab/ui';
import createFlash from '../../flash'; import createFlash from '~/flash';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import Tracking from '~/tracking';
import Icon from '../../vue_shared/components/icon.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TableRegistry from './table_registry.vue'; import TableRegistry from './table_registry.vue';
import { errorMessages, errorMessagesTypes } from '../constants'; import { DELETE_REPO_ERROR_MESSAGE } from '../constants';
import { __ } from '../../locale'; import { __ } from '~/locale';
export default { export default {
name: 'CollapsibeContainerRegisty', name: 'CollapsibeContainerRegisty',
...@@ -30,6 +31,7 @@ export default { ...@@ -30,6 +31,7 @@ export default {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective, GlModal: GlModalDirective,
}, },
mixins: [Tracking.mixin({})],
props: { props: {
repo: { repo: {
type: Object, type: Object,
...@@ -40,6 +42,10 @@ export default { ...@@ -40,6 +42,10 @@ export default {
return { return {
isOpen: false, isOpen: false,
modalId: `confirm-repo-deletion-modal-${this.repo.id}`, modalId: `confirm-repo-deletion-modal-${this.repo.id}`,
tracking: {
category: document.body.dataset.page,
label: 'registry_repository_delete',
},
}; };
}, },
computed: { computed: {
...@@ -61,15 +67,13 @@ export default { ...@@ -61,15 +67,13 @@ export default {
} }
}, },
handleDeleteRepository() { handleDeleteRepository() {
this.track('confirm_delete', {});
return this.deleteItem(this.repo) return this.deleteItem(this.repo)
.then(() => { .then(() => {
createFlash(__('This container registry has been scheduled for deletion.'), 'notice'); createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
this.fetchRepos(); this.fetchRepos();
}) })
.catch(() => this.showError(errorMessagesTypes.DELETE_REPO)); .catch(() => createFlash(DELETE_REPO_ERROR_MESSAGE));
},
showError(message) {
createFlash(errorMessages[message]);
}, },
}, },
}; };
...@@ -97,10 +101,9 @@ export default { ...@@ -97,10 +101,9 @@ export default {
v-gl-modal="modalId" v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove repository')" :title="s__('ContainerRegistry|Remove repository')"
:aria-label="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" class="js-remove-repo btn-inverted"
variant="danger" variant="danger"
@click="track('click_button', {})"
> >
<icon name="remove" /> <icon name="remove" />
</gl-button> </gl-button>
...@@ -124,7 +127,13 @@ export default { ...@@ -124,7 +127,13 @@ export default {
class="mx-auto my-0" class="mx-auto my-0"
/> />
</div> </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> <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
<p <p
v-html=" v-html="
......
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import { import { GlButton, GlFormCheckbox, GlTooltipDirective, GlModal } from '@gitlab/ui';
GlButton, import Tracking from '~/tracking';
GlFormCheckbox, import { n__, s__, sprintf } from '~/locale';
GlTooltipDirective, import createFlash from '~/flash';
GlModal, import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
GlModalDirective, import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
} from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue';
import { n__, s__, sprintf } from '../../locale'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import createFlash from '../../flash'; import { numberToHumanSize } from '~/lib/utils/number_utils';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import { FETCH_REGISTRY_ERROR_MESSAGE, DELETE_REGISTRY_ERROR_MESSAGE } from '../constants';
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';
export default { export default {
components: { components: {
...@@ -27,7 +22,6 @@ export default { ...@@ -27,7 +22,6 @@ export default {
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
}, },
mixins: [timeagoMixin], mixins: [timeagoMixin],
props: { props: {
...@@ -65,12 +59,21 @@ export default { ...@@ -65,12 +59,21 @@ export default {
this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length, this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length,
); );
}, },
}, isMultiDelete() {
mounted() { return this.itemsToBeDeleted.length > 1;
this.$refs.deleteModal.$refs.modal.$on('hide', this.removeModalEvents); },
tracking() {
return {
property: this.repo.name,
label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
};
},
}, },
methods: { methods: {
...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']), ...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']),
track(action) {
Tracking.event(document.body.dataset.page, action, this.tracking);
},
setModalDescription(itemIndex = -1) { setModalDescription(itemIndex = -1) {
if (itemIndex === -1) { if (itemIndex === -1) {
this.modalDescription = sprintf( this.modalDescription = sprintf(
...@@ -92,17 +95,11 @@ export default { ...@@ -92,17 +95,11 @@ export default {
formatSize(size) { formatSize(size) {
return numberToHumanSize(size); return numberToHumanSize(size);
}, },
removeModalEvents() {
this.$refs.deleteModal.$refs.modal.$off('ok');
},
deleteSingleItem(index) { deleteSingleItem(index) {
this.setModalDescription(index); this.setModalDescription(index);
this.itemsToBeDeleted = [index]; this.itemsToBeDeleted = [index];
this.track('click_button');
this.$refs.deleteModal.$refs.modal.$once('ok', () => { this.$refs.deleteModal.show();
this.removeModalEvents();
this.handleSingleDelete(this.repo.list[index]);
});
}, },
deleteMultipleItems() { deleteMultipleItems() {
this.itemsToBeDeleted = [...this.selectedItems]; this.itemsToBeDeleted = [...this.selectedItems];
...@@ -111,17 +108,14 @@ export default { ...@@ -111,17 +108,14 @@ export default {
} else if (this.selectedItems.length > 1) { } else if (this.selectedItems.length > 1) {
this.setModalDescription(); this.setModalDescription();
} }
this.track('click_button');
this.$refs.deleteModal.$refs.modal.$once('ok', () => { this.$refs.deleteModal.show();
this.removeModalEvents();
this.handleMultipleDelete();
});
}, },
handleSingleDelete(itemToDelete) { handleSingleDelete(itemToDelete) {
this.itemsToBeDeleted = []; this.itemsToBeDeleted = [];
this.deleteItem(itemToDelete) this.deleteItem(itemToDelete)
.then(() => this.fetchList({ repo: this.repo })) .then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE));
}, },
handleMultipleDelete() { handleMultipleDelete() {
const { itemsToBeDeleted } = this; const { itemsToBeDeleted } = this;
...@@ -134,19 +128,16 @@ export default { ...@@ -134,19 +128,16 @@ export default {
items: itemsToBeDeleted.map(x => this.repo.list[x].tag), items: itemsToBeDeleted.map(x => this.repo.list[x].tag),
}) })
.then(() => this.fetchList({ repo: this.repo })) .then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE));
} else { } else {
this.showError(errorMessagesTypes.DELETE_REGISTRY); createFlash(DELETE_REGISTRY_ERROR_MESSAGE);
} }
}, },
onPageChange(pageNumber) { onPageChange(pageNumber) {
this.fetchList({ repo: this.repo, page: pageNumber }).catch(() => this.fetchList({ repo: this.repo, page: pageNumber }).catch(() =>
this.showError(errorMessagesTypes.FETCH_REGISTRY), createFlash(FETCH_REGISTRY_ERROR_MESSAGE),
); );
}, },
showError(message) {
createFlash(errorMessages[message]);
},
onSelectAllChange() { onSelectAllChange() {
if (this.selectAllChecked) { if (this.selectAllChecked) {
this.deselectAll(); this.deselectAll();
...@@ -179,6 +170,15 @@ export default { ...@@ -179,6 +170,15 @@ export default {
canDeleteRow(item) { canDeleteRow(item) {
return item && item.canDelete && !this.isDeleteDisabled; 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> </script>
...@@ -202,12 +202,10 @@ export default { ...@@ -202,12 +202,10 @@ export default {
<th> <th>
<gl-button <gl-button
v-if="canDeleteRepo" v-if="canDeleteRepo"
ref="bulkDeleteButton"
v-gl-tooltip v-gl-tooltip
v-gl-modal="modalId"
:disabled="!selectedItems || selectedItems.length === 0" :disabled="!selectedItems || selectedItems.length === 0"
class="js-delete-registry float-right" class="float-right"
data-track-event="click_button"
data-track-label="bulk_registry_tag_delete"
variant="danger" variant="danger"
:title="s__('ContainerRegistry|Remove selected tags')" :title="s__('ContainerRegistry|Remove selected tags')"
:aria-label="s__('ContainerRegistry|Remove selected tags')" :aria-label="s__('ContainerRegistry|Remove selected tags')"
...@@ -259,11 +257,8 @@ export default { ...@@ -259,11 +257,8 @@ export default {
<td class="content action-buttons"> <td class="content action-buttons">
<gl-button <gl-button
v-if="canDeleteRow(item)" v-if="canDeleteRow(item)"
v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove tag')" :title="s__('ContainerRegistry|Remove tag')"
:aria-label="s__('ContainerRegistry|Remove tag')" :aria-label="s__('ContainerRegistry|Remove tag')"
data-track-event="click_button"
data-track-label="registry_tag_delete"
variant="danger" variant="danger"
class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon" class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon"
@click="deleteSingleItem(index)" @click="deleteSingleItem(index)"
...@@ -282,7 +277,13 @@ export default { ...@@ -282,7 +277,13 @@ export default {
class="js-registry-pagination" 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-title>{{ modalAction }}</template>
<template v-slot:modal-ok>{{ modalAction }}</template> <template v-slot:modal-ok>{{ modalAction }}</template>
<p v-html="modalDescription"></p> <p v-html="modalDescription"></p>
......
import { __ } from '../locale'; import { __ } from '../locale';
export const errorMessagesTypes = { export const FETCH_REGISTRY_ERROR_MESSAGE = __(
FETCH_REGISTRY: 'FETCH_REGISTRY', 'Something went wrong while fetching the registry list.',
FETCH_REPOS: 'FETCH_REPOS', );
DELETE_REPO: 'DELETE_REPO', export const FETCH_REPOS_ERROR_MESSAGE = __('Something went wrong while fetching the projects.');
DELETE_REGISTRY: 'DELETE_REGISTRY', export const DELETE_REPO_ERROR_MESSAGE = __('Something went wrong on our end.');
}; export const DELETE_REGISTRY_ERROR_MESSAGE = __('Something went wrong on our end.');
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.'),
};
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash'; import createFlash from '~/flash';
import * as types from './mutation_types'; 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 }) => { export const fetchRepos = ({ commit, state }) => {
commit(types.TOGGLE_MAIN_LOADING); commit(types.TOGGLE_MAIN_LOADING);
...@@ -14,7 +14,7 @@ export const fetchRepos = ({ commit, state }) => { ...@@ -14,7 +14,7 @@ export const fetchRepos = ({ commit, state }) => {
}) })
.catch(() => { .catch(() => {
commit(types.TOGGLE_MAIN_LOADING); 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 }) => { ...@@ -30,7 +30,7 @@ export const fetchList = ({ commit }, { repo, page }) => {
}) })
.catch(() => { .catch(() => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
createFlash(errorMessages[errorMessagesTypes.FETCH_REGISTRY]); createFlash(FETCH_REGISTRY_ERROR_MESSAGE);
}); });
}; };
......
import * as types from './mutation_types'; import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
export default { export default {
[types.SET_MAIN_ENDPOINT](state, endpoint) { [types.SET_MAIN_ENDPOINT](state, endpoint) {
Object.assign(state, { endpoint }); state.endpoint = endpoint;
}, },
[types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) { [types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) {
Object.assign(state, { isDeleteDisabled }); state.isDeleteDisabled = isDeleteDisabled;
}, },
[types.SET_REPOS_LIST](state, list) { [types.SET_REPOS_LIST](state, list) {
Object.assign(state, { state.repos = list.map(el => ({
repos: list.map(el => ({ canDelete: Boolean(el.destroy_path),
canDelete: Boolean(el.destroy_path), destroyPath: el.destroy_path,
destroyPath: el.destroy_path, id: el.id,
id: el.id, isLoading: false,
isLoading: false, list: [],
list: [], location: el.location,
location: el.location, name: el.path,
name: el.path, tagsPath: el.tags_path,
tagsPath: el.tags_path, projectId: el.project_id,
projectId: el.project_id, }));
})),
});
}, },
[types.TOGGLE_MAIN_LOADING](state) { [types.TOGGLE_MAIN_LOADING](state) {
Object.assign(state, { isLoading: !state.isLoading }); state.isLoading = !state.isLoading;
}, },
[types.SET_REGISTRY_LIST](state, { repo, resp, headers }) { [types.SET_REGISTRY_LIST](state, { repo, resp, headers }) {
......
/* 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 $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility'; import { visitUrl } from './lib/utils/url_utility';
...@@ -9,9 +9,8 @@ export default class TreeView { ...@@ -9,9 +9,8 @@ export default class TreeView {
// Code browser tree slider // Code browser tree slider
// Make the entire tree-item row clickable, but not if clicking another link (like a commit message) // 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) { $('.tree-content-holder .tree-item').on('click', function(e) {
var $clickedEl, path; const $clickedEl = $(e.target);
$clickedEl = $(e.target); const path = $('.tree-item-file-name a', this).attr('href');
path = $('.tree-item-file-name a', this).attr('href');
if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) { if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) {
if (e.metaKey || e.which === 2) { if (e.metaKey || e.which === 2) {
e.preventDefault(); e.preventDefault();
...@@ -26,11 +25,10 @@ export default class TreeView { ...@@ -26,11 +25,10 @@ export default class TreeView {
} }
initKeyNav() { initKeyNav() {
var li, liSelected; const li = $('tr.tree-item');
li = $('tr.tree-item'); let liSelected = null;
liSelected = null;
return $('body').keydown(e => { return $('body').keydown(e => {
var next, path; let next, path;
if ($('input:focus').length > 0 && (e.which === 38 || e.which === 40)) { if ($('input:focus').length > 0 && (e.which === 38 || e.which === 40)) {
return false; return false;
} }
......
...@@ -17,9 +17,11 @@ ...@@ -17,9 +17,11 @@
* /> * />
*/ */
import $ from 'jquery'; 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 noteHeader from '~/notes/components/note_header.vue';
import Icon from '~/vue_shared/components/icon.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 TimelineEntryItem from './timeline_entry_item.vue';
import { spriteIcon } from '../../../lib/utils/common_utils'; import { spriteIcon } from '../../../lib/utils/common_utils';
import initMRPopovers from '~/mr_popover/'; import initMRPopovers from '~/mr_popover/';
...@@ -32,7 +34,9 @@ export default { ...@@ -32,7 +34,9 @@ export default {
Icon, Icon,
noteHeader, noteHeader,
TimelineEntryItem, TimelineEntryItem,
GlSkeletonLoading,
}, },
mixins: [descriptionVersionHistoryMixin],
props: { props: {
note: { note: {
type: Object, type: Object,
...@@ -75,13 +79,16 @@ export default { ...@@ -75,13 +79,16 @@ export default {
mounted() { mounted() {
initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request')); initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request'));
}, },
methods: {
...mapActions(['fetchDescriptionVersion']),
},
}; };
</script> </script>
<template> <template>
<timeline-entry-item <timeline-entry-item
:id="noteAnchorId" :id="noteAnchorId"
:class="{ target: isTargetNote }" :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }"
class="note system-note note-wrapper" class="note system-note note-wrapper"
> >
<div class="timeline-icon" v-html="iconHtml"></div> <div class="timeline-icon" v-html="iconHtml"></div>
...@@ -89,14 +96,18 @@ export default { ...@@ -89,14 +96,18 @@ export default {
<div class="note-header"> <div class="note-header">
<note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id">
<span v-html="actionTextHtml"></span> <span v-html="actionTextHtml"></span>
<template v-if="canSeeDescriptionVersion" slot="extra-controls">
&middot;
<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> </note-header>
</div> </div>
<div class="note-body"> <div class="note-body">
<div <div
:class="{ :class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }"
'system-note-commit-list': hasMoreCommits,
'hide-shade': expanded,
}"
class="note-text md" class="note-text md"
v-html="note.note_html" v-html="note.note_html"
></div> ></div>
...@@ -106,6 +117,12 @@ export default { ...@@ -106,6 +117,12 @@ export default {
<span>{{ __('Toggle commit list') }}</span> <span>{{ __('Toggle commit list') }}</span>
</div> </div>
</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>
</div> </div>
</timeline-entry-item> </timeline-entry-item>
......
$notification-box-shadow-color: rgba(0, 0, 0, 0.25); $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
.flash-container { .flash-container {
margin-top: 10px; margin: 0;
margin-bottom: $gl-padding; margin-bottom: $gl-padding;
font-size: 14px; font-size: 14px;
position: relative; position: relative;
...@@ -41,6 +41,7 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); ...@@ -41,6 +41,7 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
.flash-success, .flash-success,
.flash-warning { .flash-warning {
padding: $gl-padding $gl-padding-32 $gl-padding ($gl-padding + $gl-padding-4); padding: $gl-padding $gl-padding-32 $gl-padding ($gl-padding + $gl-padding-4);
margin-top: 10px;
.container-fluid, .container-fluid,
.container-fluid.container-limited { .container-fluid.container-limited {
......
...@@ -310,6 +310,17 @@ $note-form-margin-left: 72px; ...@@ -310,6 +310,17 @@ $note-form-margin-left: 72px;
.note-body { .note-body {
overflow: hidden; overflow: hidden;
.description-version {
pre {
max-height: $dropdown-max-height-lg;
white-space: pre-wrap;
&.loading-state {
height: 94px;
}
}
}
.system-note-commit-list-toggler { .system-note-commit-list-toggler {
color: $blue-600; color: $blue-600;
padding: 10px 0 0; padding: 10px 0 0;
......
# 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
...@@ -10,6 +10,10 @@ class DescriptionVersion < ApplicationRecord ...@@ -10,6 +10,10 @@ class DescriptionVersion < ApplicationRecord
%i(issue merge_request).freeze %i(issue merge_request).freeze
end end
def issuable
issue || merge_request
end
private private
def exactly_one_issuable def exactly_one_issuable
......
...@@ -14,7 +14,13 @@ class PrometheusMetric < ApplicationRecord ...@@ -14,7 +14,13 @@ class PrometheusMetric < ApplicationRecord
validates :project, presence: true, unless: :common? validates :project, presence: true, unless: :common?
validates :project, absence: true, if: :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 :common, -> { where(common: true) }
scope :ordered, -> { reorder(created_at: :asc) }
def priority def priority
group_details(group).fetch(:priority) group_details(group).fetch(:priority)
......
...@@ -79,3 +79,5 @@ class NoteEntity < API::Entities::Note ...@@ -79,3 +79,5 @@ class NoteEntity < API::Entities::Note
request.current_user request.current_user
end end
end end
NoteEntity.prepend_if_ee('EE::NoteEntity')
...@@ -77,15 +77,14 @@ module Metrics ...@@ -77,15 +77,14 @@ module Metrics
# There may be multiple metrics, but they should be # There may be multiple metrics, but they should be
# displayed in a single panel/chart. # displayed in a single panel/chart.
# @return [ActiveRecord::AssociationRelation<PromtheusMetric>] # @return [ActiveRecord::AssociationRelation<PromtheusMetric>]
# rubocop: disable CodeReuse/ActiveRecord
def metrics def metrics
project.prometheus_metrics.where( PrometheusMetricsFinder.new(
project: project,
group: group_key, group: group_key,
title: title, title: title,
y_label: y_label y_label: y_label
) ).execute
end end
# rubocop: enable CodeReuse/ActiveRecord
# Returns a symbol representing the group that # Returns a symbol representing the group that
# the dashboard's group title belongs to. # the dashboard's group title belongs to.
......
...@@ -152,7 +152,7 @@ ...@@ -152,7 +152,7 @@
- email = " (#{@user.unconfirmed_email})" - email = " (#{@user.unconfirmed_email})"
%p This user has an unconfirmed email address#{email}. You may force a confirmation. %p This user has an unconfirmed email address#{email}. You may force a confirmation.
%br %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' = render_if_exists 'admin/users/user_detail_note'
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
- model = local_assigns.fetch(:model) - model = local_assigns.fetch(:model)
- form = local_assigns.fetch(:form) - 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? - supports_quick_actions = model.new_record?
- if supports_quick_actions - if supports_quick_actions
...@@ -16,7 +17,7 @@ ...@@ -16,7 +17,7 @@
= render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
= render 'projects/zen', f: form, attr: :description, = render 'projects/zen', f: form, attr: :description,
classes: 'note-textarea qa-issuable-form-description rspec-issuable-form-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 supports_quick_actions: supports_quick_actions
= render 'shared/notes/hints', supports_quick_actions: supports_quick_actions = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
.clearfix .clearfix
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
- link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project') - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project')
= link_to link_text, polymorphic_path([:leave, source, :members]), = link_to link_text, polymorphic_path([:leave, source, :members]),
method: :delete, 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' class: 'access-request-link js-leave-link'
- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord - 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]), = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
......
---
title: Fix query validation in custom metrics form
merge_request: 18769
author:
type: fixed
---
title: Improve merge request description placeholder
merge_request: 20032
author: Jacopo Beschi @jacopo-beschi
type: changed
---
title: Add event tracking to container registry
merge_request: 19772
author:
type: changed
---
title: Fix broken monitor cluster health dashboard
merge_request: 20120
author:
type: fixed
---
title: Move margin-top from flash container to flash
merge_request: 20211
author:
type: other
---
title: Remove var from bootstrap_jquery_spec.js
merge_request: 20089
author: Lee Tickett
type: other
---
title: Remove var from issue.js
merge_request: 20098
author: Lee Tickett
type: other
---
title: Remove var from labels_select.js
merge_request: 20153
author: Lee Tickett
type: other
---
title: Remove var from tree.js
merge_request: 20103
author: Lee Tickett
type: other
# 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
This diff is collapsed.
...@@ -43,7 +43,7 @@ Gitlab::Seeder.quiet do ...@@ -43,7 +43,7 @@ Gitlab::Seeder.quiet do
end end
puts "\nGenerating project labels" puts "\nGenerating project labels"
Project.all.find_each do |project| Project.not_mass_generated.find_each do |project|
Gitlab::Seeder::ProjectLabels.new(project).seed! Gitlab::Seeder::ProjectLabels.new(project).seed!
end end
end end
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
...@@ -3,7 +3,7 @@ require './spec/support/sidekiq' ...@@ -3,7 +3,7 @@ require './spec/support/sidekiq'
Sidekiq::Testing.inline! do Sidekiq::Testing.inline! do
Gitlab::Seeder.quiet do Gitlab::Seeder.quiet do
Group.all.each do |group| 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? if group.add_user(user, Gitlab::Access.values.sample).persisted?
print '.' print '.'
else else
...@@ -12,8 +12,8 @@ Sidekiq::Testing.inline! do ...@@ -12,8 +12,8 @@ Sidekiq::Testing.inline! do
end end
end end
Project.all.each do |project| Project.not_mass_generated.each do |project|
User.all.sample(4).each do |user| User.not_mass_generated.sample(4).each do |user|
if project.add_role(user, Gitlab::Access.sym_options.keys.sample) if project.add_role(user, Gitlab::Access.sym_options.keys.sample)
print '.' print '.'
else else
......
require './spec/support/sidekiq' require './spec/support/sidekiq'
Gitlab::Seeder.quiet do Gitlab::Seeder.quiet do
Project.all.each do |project| Project.not_mass_generated.each do |project|
5.times do |i| 5.times do |i|
milestone_params = { milestone_params = {
title: "v#{i}.0", title: "v#{i}.0",
......
...@@ -4,7 +4,13 @@ Gitlab::Seeder.quiet do ...@@ -4,7 +4,13 @@ Gitlab::Seeder.quiet do
# Limit the number of merge requests per project to avoid long seeds # Limit the number of merge requests per project to avoid long seeds
MAX_NUM_MERGE_REQUESTS = 10 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 = project.repository.branch_names.sample(MAX_NUM_MERGE_REQUESTS * 2)
branches.each do |branch_name| branches.each do |branch_name|
......
...@@ -9,7 +9,7 @@ Sidekiq::Testing.disable! do ...@@ -9,7 +9,7 @@ Sidekiq::Testing.disable! do
# that it falls under `Sidekiq::Testing.disable!`. # that it falls under `Sidekiq::Testing.disable!`.
Key.skip_callback(:commit, :after, :add_to_shell) 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 = "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt#{user.id + 100}6k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0="
key = user.keys.create( key = user.keys.create(
......
...@@ -25,7 +25,7 @@ end ...@@ -25,7 +25,7 @@ end
eos eos
50.times do |i| 50.times do |i|
user = User.all.sample user = User.not_mass_generated.sample
PersonalSnippet.seed(:id, [{ PersonalSnippet.seed(:id, [{
id: i, id: i,
......
...@@ -214,7 +214,7 @@ class Gitlab::Seeder::Pipelines ...@@ -214,7 +214,7 @@ class Gitlab::Seeder::Pipelines
end end
Gitlab::Seeder.quiet do 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 = Gitlab::Seeder::Pipelines.new(project)
project_builds.seed! project_builds.seed!
end end
......
...@@ -3,7 +3,7 @@ require './spec/support/sidekiq' ...@@ -3,7 +3,7 @@ require './spec/support/sidekiq'
Gitlab::Seeder.quiet do Gitlab::Seeder.quiet do
admin_user = User.find(1) admin_user = User.find(1)
Project.all.each do |project| Project.not_mass_generated.each do |project|
params = { params = {
name: 'master' name: 'master'
} }
......
...@@ -217,7 +217,7 @@ Gitlab::Seeder.quiet do ...@@ -217,7 +217,7 @@ Gitlab::Seeder.quiet do
flag = 'SEED_CYCLE_ANALYTICS' flag = 'SEED_CYCLE_ANALYTICS'
if ENV[flag] 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 # 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 # 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 # GDK seed, but is almost never true for a GDK that's actually had
......
...@@ -67,7 +67,7 @@ class Gitlab::Seeder::Environments ...@@ -67,7 +67,7 @@ class Gitlab::Seeder::Environments
end end
Gitlab::Seeder.quiet do 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 = Gitlab::Seeder::Environments.new(project)
project_environments.seed! project_environments.seed!
end end
......
...@@ -22,7 +22,7 @@ module Db ...@@ -22,7 +22,7 @@ module Db
end end
def self.random_user def self.random_user
User.find(User.pluck(:id).sample) User.find(User.not_mass_generated.pluck(:id).sample)
end end
end end
end end
......
...@@ -2,8 +2,8 @@ require './spec/support/sidekiq' ...@@ -2,8 +2,8 @@ require './spec/support/sidekiq'
Sidekiq::Testing.inline! do Sidekiq::Testing.inline! do
Gitlab::Seeder.quiet do Gitlab::Seeder.quiet do
User.all.sample(10).each do |user| User.not_mass_generated.sample(10).each do |user|
source_project = Project.public_only.sample source_project = Project.not_mass_generated.public_only.sample
## ##
# 03_project.rb might not have created a public project because # 03_project.rb might not have created a public project because
......
...@@ -18,7 +18,9 @@ You can read more about the Docker Registry at ...@@ -18,7 +18,9 @@ You can read more about the Docker Registry at
**Omnibus GitLab installations** **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 Registry will listen to. Read
[#container-registry-domain-configuration](#container-registry-domain-configuration) [#container-registry-domain-configuration](#container-registry-domain-configuration)
and pick one of the two options that fits your case. and pick one of the two options that fits your case.
......
...@@ -1219,6 +1219,10 @@ type Epic implements Noteable { ...@@ -1219,6 +1219,10 @@ type Epic implements Noteable {
hasIssues: Boolean! hasIssues: Boolean!
id: ID! id: ID!
iid: ID! iid: ID!
"""
A list of issues associated with the epic
"""
issues( issues(
""" """
Returns the elements in the list that come after the specified cursor. Returns the elements in the list that come after the specified cursor.
......
...@@ -3751,7 +3751,7 @@ ...@@ -3751,7 +3751,7 @@
}, },
{ {
"name": "issues", "name": "issues",
"description": null, "description": "A list of issues associated with the epic",
"args": [ "args": [
{ {
"name": "after", "name": "after",
......
...@@ -19,7 +19,7 @@ If you just want to delete everything and start over with an empty DB (~1 minute ...@@ -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` - `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` - `bundle exec rake dev:setup RAILS_ENV=development`
......
...@@ -12,6 +12,14 @@ The `setup` task is an alias for `gitlab:setup`. ...@@ -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. 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. 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 ### Seeding issues for all or a given project
You can seed issues for all or a given project with the `gitlab:seed:issues` You can seed issues for all or a given project with the `gitlab:seed:issues`
......
...@@ -37,7 +37,7 @@ The results are sorted by the severity of the vulnerability: ...@@ -37,7 +37,7 @@ The results are sorted by the severity of the vulnerability:
## Requirements ## 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 [`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) [`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, executor running in privileged mode. If you're using the shared Runners on GitLab.com,
...@@ -47,6 +47,8 @@ CAUTION: **Caution:** ...@@ -47,6 +47,8 @@ CAUTION: **Caution:**
If you use your own Runners, make sure that the Docker version you have installed 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. 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 ## Supported languages and package managers
The following languages and dependency managers are supported. The following languages and dependency managers are supported.
...@@ -133,6 +135,7 @@ using environment variables. ...@@ -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_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_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_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_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_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`. | | | `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 ...@@ -168,6 +171,23 @@ so that you don't have to expose your private data in `.gitlab-ci.yml` (e.g., ad
</settings> </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 ## Interacting with the vulnerabilities
Once a vulnerability is found, you can interact with it. Read more on how to Once a vulnerability is found, you can interact with it. Read more on how to
......
...@@ -9,12 +9,16 @@ module Gitlab ...@@ -9,12 +9,16 @@ module Gitlab
def instrument(_type, field) def instrument(_type, field)
service = AuthorizeFieldService.new(field) service = AuthorizeFieldService.new(field)
if service.authorizations? if service.authorizations? && !resolver_skips_authorizations?(field)
field.redefine { resolve(service.authorized_resolve) } field.redefine { resolve(service.authorized_resolve) }
else else
field field
end end
end end
def resolver_skips_authorizations?(field)
field.metadata[:resolver].try(:skip_authorizations?)
end
end end
end end
end end
......
...@@ -8,6 +8,10 @@ module Gitlab ...@@ -8,6 +8,10 @@ module Gitlab
ActiveRecord::Relation, ActiveRecord::Relation,
Gitlab::Graphql::Connections::Keyset::Connection Gitlab::Graphql::Connections::Keyset::Connection
) )
GraphQL::Relay::BaseConnection.register_connection_implementation(
Gitlab::Graphql::FilterableArray,
Gitlab::Graphql::Connections::FilterableArrayConnection
)
end end
end end
end end
......
# 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
# 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
...@@ -9,7 +9,7 @@ module Gitlab ...@@ -9,7 +9,7 @@ module Gitlab
# find a corresponding database record. If found, # find a corresponding database record. If found,
# includes the record's id in the dashboard config. # includes the record's id in the dashboard config.
def transform! def transform!
common_metrics = ::PrometheusMetric.common common_metrics = ::PrometheusMetricsFinder.new(common: true).execute
for_metrics do |metric| for_metrics do |metric|
metric_record = common_metrics.find { |m| m.identifier == metric[:id] } metric_record = common_metrics.find { |m| m.identifier == metric[:id] }
......
...@@ -9,7 +9,7 @@ module Gitlab ...@@ -9,7 +9,7 @@ module Gitlab
# config. If there are no project-specific metrics, # config. If there are no project-specific metrics,
# this will have no effect. # this will have no effect.
def transform! 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) group = find_or_create_panel_group(dashboard[:panel_groups], project_metric)
panel = find_or_create_panel(group[:panels], project_metric) panel = find_or_create_panel(group[:panels], project_metric)
find_or_create_metric(panel[:metrics], project_metric) find_or_create_metric(panel[:metrics], project_metric)
......
...@@ -11,13 +11,15 @@ module Gitlab ...@@ -11,13 +11,15 @@ module Gitlab
validates :name, :priority, :metrics, presence: true validates :name, :priority, :metrics, presence: true
def self.common_metrics def self.common_metrics
all_groups = ::PrometheusMetric.common.group_by(&:group_title).map do |name, metrics| all_groups = ::PrometheusMetricsFinder.new(common: true).execute
MetricGroup.new( .group_by(&:group_title)
name: name, .map do |name, metrics|
priority: metrics.map(&:priority).max, MetricGroup.new(
metrics: metrics.map(&:to_query_metric) name: name,
) priority: metrics.map(&:priority).max,
end metrics: metrics.map(&:to_query_metric)
)
end
all_groups.sort_by(&:priority).reverse all_groups.sort_by(&:priority).reverse
end end
......
...@@ -7,11 +7,14 @@ module Gitlab ...@@ -7,11 +7,14 @@ module Gitlab
include QueryAdditionalMetrics include QueryAdditionalMetrics
def query(serverless_function_id) def query(serverless_function_id)
PrometheusMetric PrometheusMetricsFinder
.find_by_identifier(:system_metrics_knative_function_invocation_count) .new(identifier: :system_metrics_knative_function_invocation_count, common: true)
.to_query_metric.tap do |q| .execute
q.queries[0][:result] = run_query(q.queries[0][:query_range], context(serverless_function_id)) .first
end .to_query_metric
.tap do |q|
q.queries[0][:result] = run_query(q.queries[0][:query_range], context(serverless_function_id))
end
end end
protected protected
......
...@@ -14,7 +14,71 @@ end ...@@ -14,7 +14,71 @@ end
module Gitlab module Gitlab
class Seeder 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 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_notifications
mute_mailer mute_mailer
...@@ -23,6 +87,7 @@ module Gitlab ...@@ -23,6 +87,7 @@ module Gitlab
yield yield
SeedFu.quiet = false SeedFu.quiet = false
ActiveRecord::Base.logger = old_logger
puts "\nOK".color(:green) puts "\nOK".color(:green)
end end
......
...@@ -5,6 +5,10 @@ namespace :dev do ...@@ -5,6 +5,10 @@ namespace :dev do
task setup: :environment do task setup: :environment do
ENV['force'] = 'yes' ENV['force'] = 'yes'
Rake::Task["gitlab:setup"].invoke Rake::Task["gitlab:setup"].invoke
# Make sure DB statistics are up to date.
ActiveRecord::Base.connection.execute('ANALYZE')
Rake::Task["gitlab:shell:setup"].invoke Rake::Task["gitlab:shell:setup"].invoke
end end
......
...@@ -22,7 +22,7 @@ namespace :gitlab do ...@@ -22,7 +22,7 @@ namespace :gitlab do
[project] [project]
else else
Project.find_each Project.not_mass_generated.find_each
end end
projects.each do |project| projects.each do |project|
......
...@@ -4386,6 +4386,9 @@ msgstr "" ...@@ -4386,6 +4386,9 @@ msgstr ""
msgid "Compare changes with the merge request target branch" msgid "Compare changes with the merge request target branch"
msgstr "" msgstr ""
msgid "Compare with previous version"
msgstr ""
msgid "CompareBranches|%{source_branch} and %{target_branch} are the same." msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
msgstr "" msgstr ""
...@@ -5683,6 +5686,9 @@ msgstr "" ...@@ -5683,6 +5686,9 @@ msgstr ""
msgid "Descending" msgid "Descending"
msgstr "" msgstr ""
msgid "Describe the goal of the changes and what reviewers should be aware of."
msgstr ""
msgid "Description" msgid "Description"
msgstr "" msgstr ""
...@@ -10711,9 +10717,6 @@ msgstr "" ...@@ -10711,9 +10717,6 @@ msgstr ""
msgid "MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}" msgid "MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}"
msgstr "" msgstr ""
msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}"
msgstr ""
msgid "MergeRequest|Error dismissing suggestion popover. Please try again." msgid "MergeRequest|Error dismissing suggestion popover. Please try again."
msgstr "" msgstr ""
...@@ -10858,6 +10861,9 @@ 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." 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 "" msgstr ""
msgid "Metrics|Validating query"
msgstr ""
msgid "Metrics|Y-axis label" msgid "Metrics|Y-axis label"
msgstr "" msgstr ""
...@@ -15926,6 +15932,9 @@ msgstr "" ...@@ -15926,6 +15932,9 @@ msgstr ""
msgid "Something went wrong while fetching comments. Please try again." msgid "Something went wrong while fetching comments. Please try again."
msgstr "" msgstr ""
msgid "Something went wrong while fetching description changes. Please try again."
msgstr ""
msgid "Something went wrong while fetching group member contributions" msgid "Something went wrong while fetching group member contributions"
msgstr "" msgstr ""
...@@ -21193,10 +21202,5 @@ msgstr "" ...@@ -21193,10 +21202,5 @@ msgstr ""
msgid "with %{additions} additions, %{deletions} deletions." msgid "with %{additions} additions, %{deletions} deletions."
msgstr "" msgstr ""
msgid "within %d minute "
msgid_plural "within %d minutes "
msgstr[0] ""
msgstr[1] ""
msgid "yaml invalid" msgid "yaml invalid"
msgstr "" msgstr ""
...@@ -10,9 +10,19 @@ module QA ...@@ -10,9 +10,19 @@ module QA
element :impersonate_user_link element :impersonate_user_link
end end
view 'app/views/admin/users/show.html.haml' do
element :confirm_user_button
end
def click_impersonate_user def click_impersonate_user
click_element(:impersonate_user_link) click_element(:impersonate_user_link)
end end
def confirm_user
accept_confirm do
click_element :confirm_user_button
end
end
end end
end end
end end
......
...@@ -18,6 +18,10 @@ module QA ...@@ -18,6 +18,10 @@ module QA
element :no_result_text, 'No groups or projects matched your search' # rubocop:disable QA/ElementWithPattern element :no_result_text, 'No groups or projects matched your search' # rubocop:disable QA/ElementWithPattern
end end
view 'app/views/shared/members/_access_request_links.html.haml' do
element :leave_group_link
end
def click_subgroup(name) def click_subgroup(name)
click_link name click_link name
end end
...@@ -42,6 +46,12 @@ module QA ...@@ -42,6 +46,12 @@ module QA
click_element :new_in_group_button click_element :new_in_group_button
end end
def leave_group
accept_alert do
click_element :leave_group_link
end
end
private private
def select_kind(kind) def select_kind(kind)
......
...@@ -64,12 +64,11 @@ module QA ...@@ -64,12 +64,11 @@ module QA
end end
def visit! 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 Support::Retrier.retry_until do
visit(web_url) visit(web_url)
wait { current_url.include?(URI.parse(web_url).path.split('/').last || web_url) }
wait { current_url == web_url }
end end
end end
......
...@@ -11,6 +11,10 @@ module QA ...@@ -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 } post Runtime::API::Request.new(api_client, api_members_path).url, { user_id: user.id, access_level: access_level }
end end
def list_members
JSON.parse(get(Runtime::API::Request.new(api_client, api_members_path).url).body)
end
def api_members_path def api_members_path
"#{api_get_path}/members" "#{api_get_path}/members"
end end
......
...@@ -7,6 +7,8 @@ module QA ...@@ -7,6 +7,8 @@ module QA
# creating it if it doesn't yet exist. # creating it if it doesn't yet exist.
# #
class Sandbox < Base class Sandbox < Base
include Members
attr_accessor :path attr_accessor :path
attribute :id attribute :id
......
...@@ -57,13 +57,13 @@ module QA ...@@ -57,13 +57,13 @@ module QA
Capybara.register_driver QA::Runtime::Env.browser do |app| Capybara.register_driver QA::Runtime::Env.browser do |app|
capabilities = Selenium::WebDriver::Remote::Capabilities.send(QA::Runtime::Env.browser, capabilities = Selenium::WebDriver::Remote::Capabilities.send(QA::Runtime::Env.browser,
# This enables access to logs with `page.driver.manage.get_log(:browser)` # This enables access to logs with `page.driver.manage.get_log(:browser)`
loggingPrefs: { loggingPrefs: {
browser: "ALL", browser: "ALL",
client: "ALL", client: "ALL",
driver: "ALL", driver: "ALL",
server: "ALL" server: "ALL"
}) })
if QA::Runtime::Env.accept_insecure_certs? if QA::Runtime::Env.accept_insecure_certs?
capabilities['acceptInsecureCerts'] = true capabilities['acceptInsecureCerts'] = true
......
...@@ -19,6 +19,28 @@ module QA ...@@ -19,6 +19,28 @@ module QA
set_feature(key, false) set_feature(key, false)
end 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) def enabled?(key)
feature = JSON.parse(get_features).find { |flag| flag["name"] == key } feature = JSON.parse(get_features).find { |flag| flag["name"] == key }
feature && feature["state"] == "on" feature && feature["state"] == "on"
......
...@@ -8,7 +8,9 @@ module QA ...@@ -8,7 +8,9 @@ module QA
Page::Main::Login.perform(&:sign_in_with_saml) 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') expect(page).to have_content('Welcome to GitLab')
end end
......
...@@ -7,18 +7,22 @@ module QA ...@@ -7,18 +7,22 @@ module QA
module SAMLIdp module SAMLIdp
module Page module Page
class Login < Page::Base class Login < Page::Base
def login def login(username, password)
fill_in 'username', with: 'user1' QA::Runtime::Logger.debug("Logging into SAMLIdp with username: #{username} and password:#{password}") if QA::Runtime::Env.debug?
fill_in 'password', with: 'user1pass'
fill_in 'username', with: username
fill_in 'password', with: password
click_on 'Login' click_on 'Login'
end end
def login_if_required def login_if_required(username, password)
login if login_required? login(username, password) if login_required?
end end
def login_required? 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 end
end end
......
...@@ -20,7 +20,7 @@ RSpec.configure do |config| ...@@ -20,7 +20,7 @@ RSpec.configure do |config|
QA::Specs::Helpers::Quarantine.configure_rspec QA::Specs::Helpers::Quarantine.configure_rspec
config.before do |example| 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 end
config.after(:context) do config.after(:context) do
......
...@@ -25,6 +25,11 @@ describe "User creates a merge request", :js do ...@@ -25,6 +25,11 @@ describe "User creates a merge request", :js do
click_button("Compare branches") 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) fill_in("Title", with: title)
click_button("Submit merge request") click_button("Submit merge request")
......
# 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
...@@ -81,6 +81,17 @@ describe('monitor helper', () => { ...@@ -81,6 +81,17 @@ describe('monitor helper', () => {
expect(result.name).toEqual('brpop, brpop'); 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', () => { it('updates multiple series names from templates', () => {
const config = { const config = {
...defaultConfig, ...defaultConfig,
......
...@@ -1094,8 +1094,9 @@ export const collapsedSystemNotes = [ ...@@ -1094,8 +1094,9 @@ export const collapsedSystemNotes = [
noteable_type: 'Issue', noteable_type: 'Issue',
resolvable: false, resolvable: false,
noteable_iid: 12, noteable_iid: 12,
start_description_version_id: undefined,
note: 'changed the description', 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 }, current_user: { can_edit: false, can_award_emoji: true },
resolved: false, resolved: false,
resolved_by: null, resolved_by: null,
...@@ -1106,7 +1107,6 @@ export const collapsedSystemNotes = [ ...@@ -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', '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_905&user_id=1',
human_access: 'Owner', human_access: 'Owner',
path: '/gitlab-org/gitlab-shell/notes/905', path: '/gitlab-org/gitlab-shell/notes/905',
times_updated: 2,
}, },
], ],
individual_note: true, individual_note: true,
......
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils'; 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 createFlash from '~/flash';
import Tracking from '~/tracking';
import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import * as getters from '~/registry/stores/getters'; import * as getters from '~/registry/stores/getters';
import { repoPropsData } from '../mock_data';
jest.mock('~/flash.js'); jest.mock('~/flash.js');
...@@ -16,9 +17,10 @@ describe('collapsible registry container', () => { ...@@ -16,9 +17,10 @@ describe('collapsible registry container', () => {
let wrapper; let wrapper;
let store; let store;
const findDeleteBtn = w => w.find('.js-remove-repo'); const findDeleteBtn = (w = wrapper) => w.find('.js-remove-repo');
const findContainerImageTags = w => w.find('.container-image-tags'); const findContainerImageTags = (w = wrapper) => w.find('.container-image-tags');
const findToggleRepos = w => w.findAll('.js-toggle-repo'); 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 }); const mountWithStore = config => mount(collapsibleComponent, { ...config, store, localVue });
...@@ -124,4 +126,45 @@ describe('collapsible registry container', () => { ...@@ -124,4 +126,45 @@ describe('collapsible registry container', () => {
expect(deleteBtn.exists()).toBe(false); 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,
});
});
});
}); });
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import tableRegistry from '~/registry/components/table_registry.vue';
import { mount, createLocalVue } from '@vue/test-utils'; 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 { repoPropsData } from '../mock_data';
import * as getters from '~/registry/stores/getters'; import * as getters from '~/registry/stores/getters';
jest.mock('~/flash');
const [firstImage, secondImage] = repoPropsData.list; const [firstImage, secondImage] = repoPropsData.list;
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -15,11 +19,12 @@ describe('table registry', () => { ...@@ -15,11 +19,12 @@ describe('table registry', () => {
let wrapper; let wrapper;
let store; let store;
const findSelectAllCheckbox = w => w.find('.js-select-all-checkbox > input'); const findSelectAllCheckbox = (w = wrapper) => w.find('.js-select-all-checkbox > input');
const findSelectCheckboxes = w => w.findAll('.js-select-checkbox > input'); const findSelectCheckboxes = (w = wrapper) => w.findAll('.js-select-checkbox > input');
const findDeleteButton = w => w.find('.js-delete-registry'); const findDeleteButton = (w = wrapper) => w.find({ ref: 'bulkDeleteButton' });
const findDeleteButtonsRow = w => w.findAll('.js-delete-registry-row'); const findDeleteButtonsRow = (w = wrapper) => w.findAll('.js-delete-registry-row');
const findPagination = w => w.find('.js-registry-pagination'); const findPagination = (w = wrapper) => w.find('.js-registry-pagination');
const findDeleteModal = (w = wrapper) => w.find({ ref: 'deleteModal' });
const bulkDeletePath = 'path'; const bulkDeletePath = 'path';
const mountWithStore = config => mount(tableRegistry, { ...config, store, localVue }); const mountWithStore = config => mount(tableRegistry, { ...config, store, localVue });
...@@ -139,7 +144,7 @@ describe('table registry', () => { ...@@ -139,7 +144,7 @@ describe('table registry', () => {
}, },
}); });
wrapper.vm.handleMultipleDelete(); wrapper.vm.handleMultipleDelete();
expect(wrapper.vm.showError).toHaveBeenCalled(); expect(createFlash).toHaveBeenCalled();
}); });
}); });
...@@ -169,6 +174,27 @@ describe('table registry', () => { ...@@ -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', () => { describe('pagination', () => {
const repo = { const repo = {
repoPropsData, repoPropsData,
...@@ -265,4 +291,83 @@ describe('table registry', () => { ...@@ -265,4 +291,83 @@ describe('table registry', () => {
expect(deleteBtns.length).toBe(0); 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',
});
});
});
});
}); });
...@@ -57,7 +57,7 @@ describe('system note component', () => { ...@@ -57,7 +57,7 @@ describe('system note component', () => {
// we need to strip them because they break layout of commit lists in system notes: // 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 // https://gitlab.com/gitlab-org/gitlab-foss/uploads/b07a10670919254f0220d3ff5c1aa110/jqzI.png
it('removes wrapping paragraph from note HTML', () => { 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', () => { it('should initMRPopovers onMount', () => {
......
/* eslint-disable no-var */
import $ from 'jquery'; import $ from 'jquery';
import '~/commons/bootstrap'; import '~/commons/bootstrap';
...@@ -10,15 +8,13 @@ describe('Bootstrap jQuery extensions', function() { ...@@ -10,15 +8,13 @@ describe('Bootstrap jQuery extensions', function() {
}); });
it('adds the disabled attribute', function() { it('adds the disabled attribute', function() {
var $input; const $input = $('input').first();
$input = $('input').first();
$input.disable(); $input.disable();
expect($input).toHaveAttr('disabled', 'disabled'); expect($input).toHaveAttr('disabled', 'disabled');
}); });
return it('adds the disabled class', function() { return it('adds the disabled class', function() {
var $input; const $input = $('input').first();
$input = $('input').first();
$input.disable(); $input.disable();
expect($input).toHaveClass('disabled'); expect($input).toHaveClass('disabled');
...@@ -30,15 +26,13 @@ describe('Bootstrap jQuery extensions', function() { ...@@ -30,15 +26,13 @@ describe('Bootstrap jQuery extensions', function() {
}); });
it('removes the disabled attribute', function() { it('removes the disabled attribute', function() {
var $input; const $input = $('input').first();
$input = $('input').first();
$input.enable(); $input.enable();
expect($input).not.toHaveAttr('disabled'); expect($input).not.toHaveAttr('disabled');
}); });
return it('removes the disabled class', function() { return it('removes the disabled class', function() {
var $input; const $input = $('input').first();
$input = $('input').first();
$input.enable(); $input.enable();
expect($input).not.toHaveClass('disabled'); expect($input).not.toHaveClass('disabled');
......
...@@ -122,6 +122,32 @@ describe('Dashboard', () => { ...@@ -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', () => { describe('requests information to the server', () => {
let spy; let spy;
beforeEach(() => { beforeEach(() => {
......
...@@ -144,7 +144,19 @@ describe('Monitoring mutations', () => { ...@@ -144,7 +144,19 @@ describe('Monitoring mutations', () => {
}); });
describe('SET_ALL_DASHBOARDS', () => { 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); mutations[types.SET_ALL_DASHBOARDS](stateCopy, dashboardGitResponse);
expect(stateCopy.allDashboards).toEqual(dashboardGitResponse); expect(stateCopy.allDashboards).toEqual(dashboardGitResponse);
......
import { import {
isDescriptionSystemNote, isDescriptionSystemNote,
changeDescriptionNote,
getTimeDifferenceMinutes, getTimeDifferenceMinutes,
collapseSystemNotes, collapseSystemNotes,
} from '~/notes/stores/collapse_utils'; } from '~/notes/stores/collapse_utils';
...@@ -24,15 +23,6 @@ describe('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', () => { it('gets the time difference between two notes', () => {
const anotherSystemNote = { const anotherSystemNote = {
created_at: '2018-05-14T21:33:00.000Z', created_at: '2018-05-14T21:33:00.000Z',
......
# 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
...@@ -240,38 +240,16 @@ describe Gitlab::Graphql::Connections::Keyset::Connection do ...@@ -240,38 +240,16 @@ describe Gitlab::Graphql::Connections::Keyset::Connection do
end end
describe '#paged_nodes' do 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 it_behaves_like "connection with paged nodes"
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
context 'when both are passed' do context 'when both are passed' do
let(:arguments) { { first: 2, last: 2 } } let(:arguments) { { first: 2, last: 2 } }
it 'raises an error' do 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
end end
......
...@@ -13,14 +13,19 @@ describe Gitlab::Prometheus::Queries::KnativeInvocationQuery do ...@@ -13,14 +13,19 @@ describe Gitlab::Prometheus::Queries::KnativeInvocationQuery do
context 'verify queries' do context 'verify queries' do
before do before do
allow(PrometheusMetric).to receive(:find_by_identifier).and_return(create(:prometheus_metric, query: prometheus_istio_query('test-name', 'test-ns'))) create(:prometheus_metric,
allow(client).to receive(:query_range) :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 end
it 'has the query, but no data' do 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 end
end end
...@@ -37,9 +37,12 @@ module GraphqlHelpers ...@@ -37,9 +37,12 @@ module GraphqlHelpers
# BatchLoader::GraphQL returns a wrapper, so we need to :sync in order # BatchLoader::GraphQL returns a wrapper, so we need to :sync in order
# to get the actual values # to get the actual values
def batch_sync(max_queries: nil, &blk) 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 end
def graphql_query_for(name, attributes = {}, fields = nil) def graphql_query_for(name, attributes = {}, fields = nil)
...@@ -157,7 +160,13 @@ module GraphqlHelpers ...@@ -157,7 +160,13 @@ module GraphqlHelpers
def attributes_to_graphql(attributes) def attributes_to_graphql(attributes)
attributes.map do |name, value| 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.join(", ")
end end
...@@ -282,6 +291,12 @@ module GraphqlHelpers ...@@ -282,6 +291,12 @@ module GraphqlHelpers
def allow_high_graphql_recursion def allow_high_graphql_recursion
allow_any_instance_of(Gitlab::Graphql::QueryAnalyzers::RecursionAnalyzer).to receive(:recursion_threshold).and_return 1000 allow_any_instance_of(Gitlab::Graphql::QueryAnalyzers::RecursionAnalyzer).to receive(:recursion_threshold).and_return 1000
end end
def node_array(data, extract_attribute = nil)
data.map do |item|
extract_attribute ? item['node'][extract_attribute] : item['node']
end
end
end end
# This warms our schema, doing this as part of loading the helpers to avoid # This warms our schema, doing this as part of loading the helpers to avoid
......
# 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment