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/
**/tmp/rubocop_cache/**
.overcommit.yml
.projections.json
/qa/.rakeTasks
......@@ -159,6 +159,7 @@ gem 'icalendar'
# Diffs
gem 'diffy', '~> 3.1.0'
gem 'diff_match_patch', '~> 0.1.0'
# Application server
gem 'rack', '~> 2.0.7'
......
......@@ -224,6 +224,7 @@ GEM
railties
rotp (~> 2.0)
diff-lcs (1.3)
diff_match_patch (0.1.0)
diffy (3.1.0)
discordrb-webhooks-blackst0ne (3.3.0)
rest-client (~> 2.0)
......@@ -1133,6 +1134,7 @@ DEPENDENCIES
device_detector
devise (~> 4.6)
devise-two-factor (~> 3.0.0)
diff_match_patch (~> 0.1.0)
diffy (~> 3.1.0)
discordrb-webhooks-blackst0ne (~> 3.3)
doorkeeper (~> 4.3)
......
/* eslint-disable import/prefer-default-export */
import _ from 'underscore';
/**
* @param {Array} queryResults - Array of Result objects
* @param {Object} defaultConfig - Default chart config values (e.g. lineStyle, name)
* @returns {Array} The formatted values
*/
// eslint-disable-next-line import/prefer-default-export
export const makeDataSeries = (queryResults, defaultConfig) =>
queryResults
.map(result => {
......@@ -19,10 +17,13 @@ export const makeDataSeries = (queryResults, defaultConfig) =>
if (name) {
series.name = `${defaultConfig.name}: ${name}`;
} else {
const template = _.template(defaultConfig.name, {
interpolate: /\{\{(.+?)\}\}/g,
series.name = defaultConfig.name;
Object.keys(result.metric).forEach(templateVar => {
const value = result.metric[templateVar];
const regex = new RegExp(`{{\\s*${templateVar}\\s*}}`, 'g');
series.name = series.name.replace(regex, value);
});
series.name = template(result.metric);
}
return { ...defaultConfig, ...series };
......
/* eslint-disable no-var, one-var, consistent-return */
/* eslint-disable consistent-return */
import $ from 'jquery';
import axios from './lib/utils/axios_utils';
......@@ -91,18 +91,17 @@ export default class Issue {
'click',
'.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen',
e => {
var $button, shouldSubmit, url;
e.preventDefault();
e.stopImmediatePropagation();
$button = $(e.currentTarget);
shouldSubmit = $button.hasClass('btn-comment');
const $button = $(e.currentTarget);
const shouldSubmit = $button.hasClass('btn-comment');
if (shouldSubmit) {
Issue.submitNoteForm($button.closest('form'));
}
this.disableCloseReopenButton($button);
url = $button.attr('href');
const url = $button.attr('href');
return axios
.put(url)
.then(({ data }) => {
......@@ -139,16 +138,14 @@ export default class Issue {
}
static submitNoteForm(form) {
var noteText;
noteText = form.find('textarea.js-note-text').val();
const noteText = form.find('textarea.js-note-text').val();
if (noteText && noteText.trim().length > 0) {
return form.submit();
}
}
static initRelatedBranches() {
var $container;
$container = $('#related-branches');
const $container = $('#related-branches');
return axios
.get($container.data('url'))
.then(({ data }) => {
......
This diff is collapsed.
......@@ -84,7 +84,10 @@ export const fetchDashboard = ({ state, dispatch }, params) => {
return backOffRequest(() => axios.get(state.dashboardEndpoint, { params }))
.then(resp => resp.data)
.then(response => {
dispatch('receiveMetricsDashboardSuccess', { response, params });
dispatch('receiveMetricsDashboardSuccess', {
response,
params,
});
})
.catch(error => {
dispatch('receiveMetricsDashboardFailure', error);
......
......@@ -94,7 +94,7 @@ export default {
state.emptyState = 'noData';
},
[types.SET_ALL_DASHBOARDS](state, dashboards) {
state.allDashboards = dashboards;
state.allDashboards = dashboards || [];
},
[types.SET_SHOW_ERROR_BANNER](state, enabled) {
state.showErrorBanner = enabled;
......
......@@ -101,6 +101,7 @@ export default {
<time-ago-tooltip :time="createdAt" tooltip-placement="bottom" />
</a>
</template>
<slot name="extra-controls"></slot>
<i
class="fa fa-spinner fa-spin editing-spinner"
:aria-label="__('Comment is being updated')"
......
// 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';
import loadAwardsHandler from '../../awards_handler';
import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils';
import { mergeUrlParams } from '../../lib/utils/url_utility';
import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub';
import { __ } from '~/locale';
import Api from '~/api';
......@@ -475,5 +476,20 @@ export const convertToDiscussion = ({ commit }, noteId) =>
export const removeConvertedDiscussion = ({ commit }, noteId) =>
commit(types.REMOVE_CONVERTED_DISCUSSION, noteId);
export const fetchDescriptionVersion = (_, { endpoint, startingVersion }) => {
let requestUrl = endpoint;
if (startingVersion) {
requestUrl = mergeUrlParams({ start_version_id: startingVersion }, requestUrl);
}
return axios
.get(requestUrl)
.then(res => res.data)
.catch(() => {
Flash(__('Something went wrong while fetching description changes. Please try again.'));
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import { n__, s__, sprintf } from '~/locale';
import { DESCRIPTION_TYPE } from '../constants';
/**
* Changes the description from a note, returns 'changed the description n number of times'
*/
export const changeDescriptionNote = (note, descriptionChangedTimes, timeDifferenceMinutes) => {
const descriptionNote = Object.assign({}, note);
descriptionNote.note_html = sprintf(
s__(`MergeRequest|
%{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}`),
{
paragraphStart: '<p dir="auto">',
paragraphEnd: '</p>',
descriptionChangedTimes,
timeDifferenceMinutes: n__('within %d minute ', 'within %d minutes ', timeDifferenceMinutes),
},
false,
);
descriptionNote.times_updated = descriptionChangedTimes;
return descriptionNote;
};
/**
* Checks the time difference between two notes from their 'created_at' dates
* returns an integer
*/
export const getTimeDifferenceMinutes = (noteBeggining, noteEnd) => {
const descriptionNoteBegin = new Date(noteBeggining.created_at);
const descriptionNoteEnd = new Date(noteEnd.created_at);
......@@ -57,7 +32,6 @@ export const isDescriptionSystemNote = note => note.system && note.note === DESC
export const collapseSystemNotes = notes => {
let lastDescriptionSystemNote = null;
let lastDescriptionSystemNoteIndex = -1;
let descriptionChangedTimes = 1;
return notes.slice(0).reduce((acc, currentNote) => {
const note = currentNote.notes[0];
......@@ -70,32 +44,24 @@ export const collapseSystemNotes = notes => {
} else if (lastDescriptionSystemNote) {
const timeDifferenceMinutes = getTimeDifferenceMinutes(lastDescriptionSystemNote, note);
// are they less than 10 minutes apart?
if (timeDifferenceMinutes > 10) {
// reset counter
descriptionChangedTimes = 1;
// are they less than 10 minutes apart from the same user?
if (timeDifferenceMinutes > 10 || note.author.id !== lastDescriptionSystemNote.author.id) {
// update the previous system note
lastDescriptionSystemNote = note;
lastDescriptionSystemNoteIndex = acc.length;
} else {
// increase counter
descriptionChangedTimes += 1;
// set the first version to fetch grouped system note versions
note.start_description_version_id = lastDescriptionSystemNote.description_version_id;
// delete the previous one
acc.splice(lastDescriptionSystemNoteIndex, 1);
// replace the text of the current system note with the collapsed note.
currentNote.notes.splice(
0,
1,
changeDescriptionNote(note, descriptionChangedTimes, timeDifferenceMinutes),
);
// update the previous system note index
lastDescriptionSystemNoteIndex = acc.length;
}
}
}
acc.push(currentNote);
return acc;
}, []);
......
......@@ -8,12 +8,13 @@ import {
GlModalDirective,
GlEmptyState,
} from '@gitlab/ui';
import createFlash from '../../flash';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import Icon from '../../vue_shared/components/icon.vue';
import createFlash from '~/flash';
import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TableRegistry from './table_registry.vue';
import { errorMessages, errorMessagesTypes } from '../constants';
import { __ } from '../../locale';
import { DELETE_REPO_ERROR_MESSAGE } from '../constants';
import { __ } from '~/locale';
export default {
name: 'CollapsibeContainerRegisty',
......@@ -30,6 +31,7 @@ export default {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
mixins: [Tracking.mixin({})],
props: {
repo: {
type: Object,
......@@ -40,6 +42,10 @@ export default {
return {
isOpen: false,
modalId: `confirm-repo-deletion-modal-${this.repo.id}`,
tracking: {
category: document.body.dataset.page,
label: 'registry_repository_delete',
},
};
},
computed: {
......@@ -61,15 +67,13 @@ export default {
}
},
handleDeleteRepository() {
this.track('confirm_delete', {});
return this.deleteItem(this.repo)
.then(() => {
createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
this.fetchRepos();
})
.catch(() => this.showError(errorMessagesTypes.DELETE_REPO));
},
showError(message) {
createFlash(errorMessages[message]);
.catch(() => createFlash(DELETE_REPO_ERROR_MESSAGE));
},
},
};
......@@ -97,10 +101,9 @@ export default {
v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
data-track-event="click_button"
data-track-label="registry_repository_delete"
class="js-remove-repo btn-inverted"
variant="danger"
@click="track('click_button', {})"
>
<icon name="remove" />
</gl-button>
......@@ -124,7 +127,13 @@ export default {
class="mx-auto my-0"
/>
</div>
<gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository">
<gl-modal
ref="deleteModal"
:modal-id="modalId"
ok-variant="danger"
@ok="handleDeleteRepository"
@cancel="track('cancel_delete', {})"
>
<template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
<p
v-html="
......
<script>
import { mapActions, mapGetters } from 'vuex';
import {
GlButton,
GlFormCheckbox,
GlTooltipDirective,
GlModal,
GlModalDirective,
} from '@gitlab/ui';
import { n__, s__, sprintf } from '../../locale';
import createFlash from '../../flash';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue';
import Icon from '../../vue_shared/components/icon.vue';
import timeagoMixin from '../../vue_shared/mixins/timeago';
import { errorMessages, errorMessagesTypes } from '../constants';
import { numberToHumanSize } from '../../lib/utils/number_utils';
import { GlButton, GlFormCheckbox, GlTooltipDirective, GlModal } from '@gitlab/ui';
import Tracking from '~/tracking';
import { n__, s__, sprintf } from '~/locale';
import createFlash from '~/flash';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import Icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { FETCH_REGISTRY_ERROR_MESSAGE, DELETE_REGISTRY_ERROR_MESSAGE } from '../constants';
export default {
components: {
......@@ -27,7 +22,6 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
mixins: [timeagoMixin],
props: {
......@@ -65,12 +59,21 @@ export default {
this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length,
);
},
},
mounted() {
this.$refs.deleteModal.$refs.modal.$on('hide', this.removeModalEvents);
isMultiDelete() {
return this.itemsToBeDeleted.length > 1;
},
tracking() {
return {
property: this.repo.name,
label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
};
},
},
methods: {
...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']),
track(action) {
Tracking.event(document.body.dataset.page, action, this.tracking);
},
setModalDescription(itemIndex = -1) {
if (itemIndex === -1) {
this.modalDescription = sprintf(
......@@ -92,17 +95,11 @@ export default {
formatSize(size) {
return numberToHumanSize(size);
},
removeModalEvents() {
this.$refs.deleteModal.$refs.modal.$off('ok');
},
deleteSingleItem(index) {
this.setModalDescription(index);
this.itemsToBeDeleted = [index];
this.$refs.deleteModal.$refs.modal.$once('ok', () => {
this.removeModalEvents();
this.handleSingleDelete(this.repo.list[index]);
});
this.track('click_button');
this.$refs.deleteModal.show();
},
deleteMultipleItems() {
this.itemsToBeDeleted = [...this.selectedItems];
......@@ -111,17 +108,14 @@ export default {
} else if (this.selectedItems.length > 1) {
this.setModalDescription();
}
this.$refs.deleteModal.$refs.modal.$once('ok', () => {
this.removeModalEvents();
this.handleMultipleDelete();
});
this.track('click_button');
this.$refs.deleteModal.show();
},
handleSingleDelete(itemToDelete) {
this.itemsToBeDeleted = [];
this.deleteItem(itemToDelete)
.then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
.catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE));
},
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
......@@ -134,19 +128,16 @@ export default {
items: itemsToBeDeleted.map(x => this.repo.list[x].tag),
})
.then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
.catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE));
} else {
this.showError(errorMessagesTypes.DELETE_REGISTRY);
createFlash(DELETE_REGISTRY_ERROR_MESSAGE);
}
},
onPageChange(pageNumber) {
this.fetchList({ repo: this.repo, page: pageNumber }).catch(() =>
this.showError(errorMessagesTypes.FETCH_REGISTRY),
createFlash(FETCH_REGISTRY_ERROR_MESSAGE),
);
},
showError(message) {
createFlash(errorMessages[message]);
},
onSelectAllChange() {
if (this.selectAllChecked) {
this.deselectAll();
......@@ -179,6 +170,15 @@ export default {
canDeleteRow(item) {
return item && item.canDelete && !this.isDeleteDisabled;
},
onDeletionConfirmed() {
this.track('confirm_delete');
if (this.isMultiDelete) {
this.handleMultipleDelete();
} else {
const index = this.itemsToBeDeleted[0];
this.handleSingleDelete(this.repo.list[index]);
}
},
},
};
</script>
......@@ -202,12 +202,10 @@ export default {
<th>
<gl-button
v-if="canDeleteRepo"
ref="bulkDeleteButton"
v-gl-tooltip
v-gl-modal="modalId"
:disabled="!selectedItems || selectedItems.length === 0"
class="js-delete-registry float-right"
data-track-event="click_button"
data-track-label="bulk_registry_tag_delete"
class="float-right"
variant="danger"
:title="s__('ContainerRegistry|Remove selected tags')"
:aria-label="s__('ContainerRegistry|Remove selected tags')"
......@@ -259,11 +257,8 @@ export default {
<td class="content action-buttons">
<gl-button
v-if="canDeleteRow(item)"
v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove tag')"
:aria-label="s__('ContainerRegistry|Remove tag')"
data-track-event="click_button"
data-track-label="registry_tag_delete"
variant="danger"
class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon"
@click="deleteSingleItem(index)"
......@@ -282,7 +277,13 @@ export default {
class="js-registry-pagination"
/>
<gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger">
<gl-modal
ref="deleteModal"
:modal-id="modalId"
ok-variant="danger"
@ok="onDeletionConfirmed"
@cancel="track('cancel_delete')"
>
<template v-slot:modal-title>{{ modalAction }}</template>
<template v-slot:modal-ok>{{ modalAction }}</template>
<p v-html="modalDescription"></p>
......
import { __ } from '../locale';
export const errorMessagesTypes = {
FETCH_REGISTRY: 'FETCH_REGISTRY',
FETCH_REPOS: 'FETCH_REPOS',
DELETE_REPO: 'DELETE_REPO',
DELETE_REGISTRY: 'DELETE_REGISTRY',
};
export const errorMessages = {
[errorMessagesTypes.FETCH_REGISTRY]: __('Something went wrong while fetching the registry list.'),
[errorMessagesTypes.FETCH_REPOS]: __('Something went wrong while fetching the projects.'),
[errorMessagesTypes.DELETE_REPO]: __('Something went wrong on our end.'),
[errorMessagesTypes.DELETE_REGISTRY]: __('Something went wrong on our end.'),
};
export const FETCH_REGISTRY_ERROR_MESSAGE = __(
'Something went wrong while fetching the registry list.',
);
export const FETCH_REPOS_ERROR_MESSAGE = __('Something went wrong while fetching the projects.');
export const DELETE_REPO_ERROR_MESSAGE = __('Something went wrong on our end.');
export const DELETE_REGISTRY_ERROR_MESSAGE = __('Something went wrong on our end.');
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import * as types from './mutation_types';
import { errorMessages, errorMessagesTypes } from '../constants';
import { FETCH_REPOS_ERROR_MESSAGE, FETCH_REGISTRY_ERROR_MESSAGE } from '../constants';
export const fetchRepos = ({ commit, state }) => {
commit(types.TOGGLE_MAIN_LOADING);
......@@ -14,7 +14,7 @@ export const fetchRepos = ({ commit, state }) => {
})
.catch(() => {
commit(types.TOGGLE_MAIN_LOADING);
createFlash(errorMessages[errorMessagesTypes.FETCH_REPOS]);
createFlash(FETCH_REPOS_ERROR_MESSAGE);
});
};
......@@ -30,7 +30,7 @@ export const fetchList = ({ commit }, { repo, page }) => {
})
.catch(() => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
createFlash(errorMessages[errorMessagesTypes.FETCH_REGISTRY]);
createFlash(FETCH_REGISTRY_ERROR_MESSAGE);
});
};
......
import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
export default {
[types.SET_MAIN_ENDPOINT](state, endpoint) {
Object.assign(state, { endpoint });
state.endpoint = endpoint;
},
[types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) {
Object.assign(state, { isDeleteDisabled });
state.isDeleteDisabled = isDeleteDisabled;
},
[types.SET_REPOS_LIST](state, list) {
Object.assign(state, {
repos: list.map(el => ({
canDelete: Boolean(el.destroy_path),
destroyPath: el.destroy_path,
id: el.id,
isLoading: false,
list: [],
location: el.location,
name: el.path,
tagsPath: el.tags_path,
projectId: el.project_id,
})),
});
state.repos = list.map(el => ({
canDelete: Boolean(el.destroy_path),
destroyPath: el.destroy_path,
id: el.id,
isLoading: false,
list: [],
location: el.location,
name: el.path,
tagsPath: el.tags_path,
projectId: el.project_id,
}));
},
[types.TOGGLE_MAIN_LOADING](state) {
Object.assign(state, { isLoading: !state.isLoading });
state.isLoading = !state.isLoading;
},
[types.SET_REGISTRY_LIST](state, { repo, resp, headers }) {
......
/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, class-methods-use-this */
/* eslint-disable func-names, consistent-return, one-var, no-else-return, class-methods-use-this */
import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility';
......@@ -9,9 +9,8 @@ export default class TreeView {
// Code browser tree slider
// Make the entire tree-item row clickable, but not if clicking another link (like a commit message)
$('.tree-content-holder .tree-item').on('click', function(e) {
var $clickedEl, path;
$clickedEl = $(e.target);
path = $('.tree-item-file-name a', this).attr('href');
const $clickedEl = $(e.target);
const path = $('.tree-item-file-name a', this).attr('href');
if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) {
if (e.metaKey || e.which === 2) {
e.preventDefault();
......@@ -26,11 +25,10 @@ export default class TreeView {
}
initKeyNav() {
var li, liSelected;
li = $('tr.tree-item');
liSelected = null;
const li = $('tr.tree-item');
let liSelected = null;
return $('body').keydown(e => {
var next, path;
let next, path;
if ($('input:focus').length > 0 && (e.which === 38 || e.which === 40)) {
return false;
}
......
......@@ -17,9 +17,11 @@
* />
*/
import $ from 'jquery';
import { mapGetters } from 'vuex';
import { mapGetters, mapActions } from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui';
import noteHeader from '~/notes/components/note_header.vue';
import Icon from '~/vue_shared/components/icon.vue';
import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
import TimelineEntryItem from './timeline_entry_item.vue';
import { spriteIcon } from '../../../lib/utils/common_utils';
import initMRPopovers from '~/mr_popover/';
......@@ -32,7 +34,9 @@ export default {
Icon,
noteHeader,
TimelineEntryItem,
GlSkeletonLoading,
},
mixins: [descriptionVersionHistoryMixin],
props: {
note: {
type: Object,
......@@ -75,13 +79,16 @@ export default {
mounted() {
initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request'));
},
methods: {
...mapActions(['fetchDescriptionVersion']),
},
};
</script>
<template>
<timeline-entry-item
:id="noteAnchorId"
:class="{ target: isTargetNote }"
:class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }"
class="note system-note note-wrapper"
>
<div class="timeline-icon" v-html="iconHtml"></div>
......@@ -89,14 +96,18 @@ export default {
<div class="note-header">
<note-header :author="note.author" :created-at="note.created_at" :note-id="note.id">
<span v-html="actionTextHtml"></span>
<template v-if="canSeeDescriptionVersion" slot="extra-controls">
&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>
</div>
<div class="note-body">
<div
:class="{
'system-note-commit-list': hasMoreCommits,
'hide-shade': expanded,
}"
:class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }"
class="note-text md"
v-html="note.note_html"
></div>
......@@ -106,6 +117,12 @@ export default {
<span>{{ __('Toggle commit list') }}</span>
</div>
</div>
<div v-if="shouldShowDescriptionVersion" class="description-version pt-2">
<pre v-if="isLoadingDescriptionVersion" class="loading-state">
<gl-skeleton-loading />
</pre>
<pre v-else class="wrapper mt-2" v-html="descriptionVersion"></pre>
</div>
</div>
</div>
</timeline-entry-item>
......
$notification-box-shadow-color: rgba(0, 0, 0, 0.25);
.flash-container {
margin-top: 10px;
margin: 0;
margin-bottom: $gl-padding;
font-size: 14px;
position: relative;
......@@ -41,6 +41,7 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
.flash-success,
.flash-warning {
padding: $gl-padding $gl-padding-32 $gl-padding ($gl-padding + $gl-padding-4);
margin-top: 10px;
.container-fluid,
.container-fluid.container-limited {
......
......@@ -310,6 +310,17 @@ $note-form-margin-left: 72px;
.note-body {
overflow: hidden;
.description-version {
pre {
max-height: $dropdown-max-height-lg;
white-space: pre-wrap;
&.loading-state {
height: 94px;
}
}
}
.system-note-commit-list-toggler {
color: $blue-600;
padding: 10px 0 0;
......
# 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
%i(issue merge_request).freeze
end
def issuable
issue || merge_request
end
private
def exactly_one_issuable
......
......@@ -14,7 +14,13 @@ class PrometheusMetric < ApplicationRecord
validates :project, presence: true, unless: :common?
validates :project, absence: true, if: :common?
scope :for_project, -> (project) { where(project: project) }
scope :for_group, -> (group) { where(group: group) }
scope :for_title, -> (title) { where(title: title) }
scope :for_y_label, -> (y_label) { where(y_label: y_label) }
scope :for_identifier, -> (identifier) { where(identifier: identifier) }
scope :common, -> { where(common: true) }
scope :ordered, -> { reorder(created_at: :asc) }
def priority
group_details(group).fetch(:priority)
......
......@@ -79,3 +79,5 @@ class NoteEntity < API::Entities::Note
request.current_user
end
end
NoteEntity.prepend_if_ee('EE::NoteEntity')
......@@ -77,15 +77,14 @@ module Metrics
# There may be multiple metrics, but they should be
# displayed in a single panel/chart.
# @return [ActiveRecord::AssociationRelation<PromtheusMetric>]
# rubocop: disable CodeReuse/ActiveRecord
def metrics
project.prometheus_metrics.where(
PrometheusMetricsFinder.new(
project: project,
group: group_key,
title: title,
y_label: y_label
)
).execute
end
# rubocop: enable CodeReuse/ActiveRecord
# Returns a symbol representing the group that
# the dashboard's group title belongs to.
......
......@@ -152,7 +152,7 @@
- email = " (#{@user.unconfirmed_email})"
%p This user has an unconfirmed email address#{email}. You may force a confirmation.
%br
= link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' }
= link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?', qa_selector: 'confirm_user_button' }
= render_if_exists 'admin/users/user_detail_note'
......
......@@ -2,6 +2,7 @@
- model = local_assigns.fetch(:model)
- form = local_assigns.fetch(:form)
- placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a comment or drag your files here…')
- supports_quick_actions = model.new_record?
- if supports_quick_actions
......@@ -16,7 +17,7 @@
= render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
= render 'projects/zen', f: form, attr: :description,
classes: 'note-textarea qa-issuable-form-description rspec-issuable-form-description',
placeholder: "Write a comment or drag your files here…",
placeholder: placeholder,
supports_quick_actions: supports_quick_actions
= render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
.clearfix
......
......@@ -4,7 +4,7 @@
- link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project')
= link_to link_text, polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: leave_confirmation_message(source) },
data: { confirm: leave_confirmation_message(source), qa_selector: 'leave_group_link' },
class: 'access-request-link js-leave-link'
- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord
= link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
......
---
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
end
puts "\nGenerating project labels"
Project.all.find_each do |project|
Project.not_mass_generated.find_each do |project|
Gitlab::Seeder::ProjectLabels.new(project).seed!
end
end
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'
Sidekiq::Testing.inline! do
Gitlab::Seeder.quiet do
Group.all.each do |group|
User.all.sample(4).each do |user|
User.not_mass_generated.sample(4).each do |user|
if group.add_user(user, Gitlab::Access.values.sample).persisted?
print '.'
else
......@@ -12,8 +12,8 @@ Sidekiq::Testing.inline! do
end
end
Project.all.each do |project|
User.all.sample(4).each do |user|
Project.not_mass_generated.each do |project|
User.not_mass_generated.sample(4).each do |user|
if project.add_role(user, Gitlab::Access.sym_options.keys.sample)
print '.'
else
......
require './spec/support/sidekiq'
Gitlab::Seeder.quiet do
Project.all.each do |project|
Project.not_mass_generated.each do |project|
5.times do |i|
milestone_params = {
title: "v#{i}.0",
......
......@@ -4,7 +4,13 @@ Gitlab::Seeder.quiet do
# Limit the number of merge requests per project to avoid long seeds
MAX_NUM_MERGE_REQUESTS = 10
Project.non_archived.with_merge_requests_enabled.reject(&:empty_repo?).each do |project|
projects = Project
.non_archived
.with_merge_requests_enabled
.not_mass_generated
.reject(&:empty_repo?)
projects.each do |project|
branches = project.repository.branch_names.sample(MAX_NUM_MERGE_REQUESTS * 2)
branches.each do |branch_name|
......
......@@ -9,7 +9,7 @@ Sidekiq::Testing.disable! do
# that it falls under `Sidekiq::Testing.disable!`.
Key.skip_callback(:commit, :after, :add_to_shell)
User.first(10).each do |user|
User.not_mass_generated.first(10).each do |user|
key = "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt#{user.id + 100}6k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0="
key = user.keys.create(
......
......@@ -25,7 +25,7 @@ end
eos
50.times do |i|
user = User.all.sample
user = User.not_mass_generated.sample
PersonalSnippet.seed(:id, [{
id: i,
......
......@@ -214,7 +214,7 @@ class Gitlab::Seeder::Pipelines
end
Gitlab::Seeder.quiet do
Project.all.sample(5).each do |project|
Project.not_mass_generated.sample(5).each do |project|
project_builds = Gitlab::Seeder::Pipelines.new(project)
project_builds.seed!
end
......
......@@ -3,7 +3,7 @@ require './spec/support/sidekiq'
Gitlab::Seeder.quiet do
admin_user = User.find(1)
Project.all.each do |project|
Project.not_mass_generated.each do |project|
params = {
name: 'master'
}
......
......@@ -217,7 +217,7 @@ Gitlab::Seeder.quiet do
flag = 'SEED_CYCLE_ANALYTICS'
if ENV[flag]
Project.find_each do |project|
Project.not_mass_generated.find_each do |project|
# This seed naively assumes that every project has a repository, and every
# repository has a `master` branch, which may be the case for a pristine
# GDK seed, but is almost never true for a GDK that's actually had
......
......@@ -67,7 +67,7 @@ class Gitlab::Seeder::Environments
end
Gitlab::Seeder.quiet do
Project.all.sample(5).each do |project|
Project.not_mass_generated.sample(5).each do |project|
project_environments = Gitlab::Seeder::Environments.new(project)
project_environments.seed!
end
......
......@@ -22,7 +22,7 @@ module Db
end
def self.random_user
User.find(User.pluck(:id).sample)
User.find(User.not_mass_generated.pluck(:id).sample)
end
end
end
......
......@@ -2,8 +2,8 @@ require './spec/support/sidekiq'
Sidekiq::Testing.inline! do
Gitlab::Seeder.quiet do
User.all.sample(10).each do |user|
source_project = Project.public_only.sample
User.not_mass_generated.sample(10).each do |user|
source_project = Project.not_mass_generated.public_only.sample
##
# 03_project.rb might not have created a public project because
......
......@@ -18,7 +18,9 @@ You can read more about the Docker Registry at
**Omnibus GitLab installations**
All you have to do is configure the domain name under which the Container
If you are using the Omnibus GitLab built in [Let's Encrypt integration](https://docs.gitlab.com/omnibus/settings/ssl.html#lets-encrypt-integration), as of GitLab 12.5, the Container Registry will be automatically enabled on port 5050 of the default domain.
If you would like to use a separate domain, all you have to do is configure the domain name under which the Container
Registry will listen to. Read
[#container-registry-domain-configuration](#container-registry-domain-configuration)
and pick one of the two options that fits your case.
......
......@@ -1219,6 +1219,10 @@ type Epic implements Noteable {
hasIssues: Boolean!
id: ID!
iid: ID!
"""
A list of issues associated with the epic
"""
issues(
"""
Returns the elements in the list that come after the specified cursor.
......
......@@ -3751,7 +3751,7 @@
},
{
"name": "issues",
"description": null,
"description": "A list of issues associated with the epic",
"args": [
{
"name": "after",
......
......@@ -19,7 +19,7 @@ If you just want to delete everything and start over with an empty DB (~1 minute
- `bundle exec rake db:reset RAILS_ENV=development`
If you just want to delete everything and start over with dummy data (~40 minutes). This also does `db:reset` and runs DB-specific migrations:
If you just want to delete everything and start over with dummy data (~4 minutes). This also does `db:reset` and runs DB-specific migrations:
- `bundle exec rake dev:setup RAILS_ENV=development`
......
......@@ -12,6 +12,14 @@ The `setup` task is an alias for `gitlab:setup`.
This tasks calls `db:reset` to create the database, and calls `db:seed_fu` to seed the database.
Note: `db:setup` calls `db:seed` but this does nothing.
### Env variables
**MASS_INSERT**: Create millions of users (2m), projects (5m) and its
relations. It's highly recommended to run the seed with it to catch slow queries
while developing. Expect the process to take up to 20 extra minutes.
**LARGE_PROJECTS**: Create large projects (through import) from a predefined set of urls.
### Seeding issues for all or a given project
You can seed issues for all or a given project with the `gitlab:seed:issues`
......
......@@ -37,7 +37,7 @@ The results are sorted by the severity of the vulnerability:
## Requirements
To run a Dependency Scanning job, you need GitLab Runner with the
To run a Dependency Scanning job, by default, you need GitLab Runner with the
[`docker`](https://docs.gitlab.com/runner/executors/docker.html#use-docker-in-docker-with-privileged-mode) or
[`kubernetes`](https://docs.gitlab.com/runner/install/kubernetes.html#running-privileged-containers-for-the-runners)
executor running in privileged mode. If you're using the shared Runners on GitLab.com,
......@@ -47,6 +47,8 @@ CAUTION: **Caution:**
If you use your own Runners, make sure that the Docker version you have installed
is **not** `19.03.00`. See [troubleshooting information](#error-response-from-daemon-error-processing-tar-file-docker-tar-relocation-error) for details.
Privileged mode is not necessary if you've [disabled Docker in Docker for Dependency Scanning](#disabling-docker-in-docker-for-dependency-scanning)
## Supported languages and package managers
The following languages and dependency managers are supported.
......@@ -133,6 +135,7 @@ using environment variables.
| `DS_PYTHON_VERSION` | Version of Python. If set to 2, dependencies are installed using Python 2.7 instead of Python 3.6. ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12296) in GitLab 12.1)| |
| `DS_PIP_DEPENDENCY_PATH` | Path to load Python pip dependencies from. ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12412) in GitLab 12.2) | |
| `DS_DEFAULT_ANALYZERS` | Override the names of the official default images. Read more about [customizing analyzers](analyzers.md). | |
| `DS_DISABLE_DIND` | Disable Docker in Docker and run analyzers [individually](#disabling-docker-in-docker-for-dependency-scanning).| |
| `DS_PULL_ANALYZER_IMAGES` | Pull the images from the Docker registry (set to `0` to disable). | |
| `DS_EXCLUDED_PATHS` | Exclude vulnerabilities from output based on the paths. A comma-separated list of patterns. Patterns can be globs, file or folder paths. Parent directories will also match patterns. | `DS_EXCLUDED_PATHS=doc,spec` |
| `DS_DOCKER_CLIENT_NEGOTIATION_TIMEOUT` | Time limit for Docker client negotiation. Timeouts are parsed using Go's [`ParseDuration`](https://golang.org/pkg/time/#ParseDuration). Valid time units are `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`. For example, `300ms`, `1.5h`, or `2h45m`. | |
......@@ -168,6 +171,23 @@ so that you don't have to expose your private data in `.gitlab-ci.yml` (e.g., ad
</settings>
```
### Disabling Docker in Docker for Dependency Scanning
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12487) in GitLab Ultimate 12.5.
You can avoid the need for Docker in Docker by running the individual analyzers.
This does not require running the executor in privileged mode. For example:
```yaml
include:
template: Dependency-Scanning.gitlab-ci.yml
variables:
DS_DISABLE_DIND: "true"
```
This will create individual `<analyzer-name>-dependency_scanning` jobs for each analyzer that runs in your CI/CD pipeline.
## Interacting with the vulnerabilities
Once a vulnerability is found, you can interact with it. Read more on how to
......
......@@ -9,12 +9,16 @@ module Gitlab
def instrument(_type, field)
service = AuthorizeFieldService.new(field)
if service.authorizations?
if service.authorizations? && !resolver_skips_authorizations?(field)
field.redefine { resolve(service.authorized_resolve) }
else
field
end
end
def resolver_skips_authorizations?(field)
field.metadata[:resolver].try(:skip_authorizations?)
end
end
end
end
......
......@@ -8,6 +8,10 @@ module Gitlab
ActiveRecord::Relation,
Gitlab::Graphql::Connections::Keyset::Connection
)
GraphQL::Relay::BaseConnection.register_connection_implementation(
Gitlab::Graphql::FilterableArray,
Gitlab::Graphql::Connections::FilterableArrayConnection
)
end
end
end
......
# 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
# find a corresponding database record. If found,
# includes the record's id in the dashboard config.
def transform!
common_metrics = ::PrometheusMetric.common
common_metrics = ::PrometheusMetricsFinder.new(common: true).execute
for_metrics do |metric|
metric_record = common_metrics.find { |m| m.identifier == metric[:id] }
......
......@@ -9,7 +9,7 @@ module Gitlab
# config. If there are no project-specific metrics,
# this will have no effect.
def transform!
project.prometheus_metrics.each do |project_metric|
PrometheusMetricsFinder.new(project: project).execute.each do |project_metric|
group = find_or_create_panel_group(dashboard[:panel_groups], project_metric)
panel = find_or_create_panel(group[:panels], project_metric)
find_or_create_metric(panel[:metrics], project_metric)
......
......@@ -11,13 +11,15 @@ module Gitlab
validates :name, :priority, :metrics, presence: true
def self.common_metrics
all_groups = ::PrometheusMetric.common.group_by(&:group_title).map do |name, metrics|
MetricGroup.new(
name: name,
priority: metrics.map(&:priority).max,
metrics: metrics.map(&:to_query_metric)
)
end
all_groups = ::PrometheusMetricsFinder.new(common: true).execute
.group_by(&:group_title)
.map do |name, metrics|
MetricGroup.new(
name: name,
priority: metrics.map(&:priority).max,
metrics: metrics.map(&:to_query_metric)
)
end
all_groups.sort_by(&:priority).reverse
end
......
......@@ -7,11 +7,14 @@ module Gitlab
include QueryAdditionalMetrics
def query(serverless_function_id)
PrometheusMetric
.find_by_identifier(:system_metrics_knative_function_invocation_count)
.to_query_metric.tap do |q|
q.queries[0][:result] = run_query(q.queries[0][:query_range], context(serverless_function_id))
end
PrometheusMetricsFinder
.new(identifier: :system_metrics_knative_function_invocation_count, common: true)
.execute
.first
.to_query_metric
.tap do |q|
q.queries[0][:result] = run_query(q.queries[0][:query_range], context(serverless_function_id))
end
end
protected
......
......@@ -14,7 +14,71 @@ end
module Gitlab
class Seeder
extend ActionView::Helpers::NumberHelper
ESTIMATED_INSERT_PER_MINUTE = 2_000_000
MASS_INSERT_ENV = 'MASS_INSERT'
module ProjectSeed
extend ActiveSupport::Concern
included do
scope :not_mass_generated, -> do
where.not("path LIKE '#{Gitlab::Seeder::Projects::MASS_INSERT_NAME_START}%'")
end
end
end
module UserSeed
extend ActiveSupport::Concern
included do
scope :not_mass_generated, -> do
where.not("username LIKE '#{Gitlab::Seeder::Users::MASS_INSERT_USERNAME_START}%'")
end
end
end
def self.with_mass_insert(size, model)
humanized_model_name = model.is_a?(String) ? model : model.model_name.human.pluralize(size)
if !ENV[MASS_INSERT_ENV] && !ENV['CI']
puts "\nSkipping mass insertion for #{humanized_model_name}."
puts "Consider running the seed with #{MASS_INSERT_ENV}=1"
return
end
humanized_size = number_with_delimiter(size)
estimative = estimated_time_message(size)
puts "\nCreating #{humanized_size} #{humanized_model_name}."
puts estimative
yield
puts "\n#{number_with_delimiter(size)} #{humanized_model_name} created!"
end
def self.estimated_time_message(size)
estimated_minutes = (size.to_f / ESTIMATED_INSERT_PER_MINUTE).round
humanized_minutes = 'minute'.pluralize(estimated_minutes)
if estimated_minutes.zero?
"Rough estimated time: less than a minute ⏰"
else
"Rough estimated time: #{estimated_minutes} #{humanized_minutes} ⏰"
end
end
def self.quiet
# Disable database insertion logs so speed isn't limited by ability to print to console
old_logger = ActiveRecord::Base.logger
ActiveRecord::Base.logger = nil
# Additional seed logic for models.
Project.include(ProjectSeed)
User.include(UserSeed)
mute_notifications
mute_mailer
......@@ -23,6 +87,7 @@ module Gitlab
yield
SeedFu.quiet = false
ActiveRecord::Base.logger = old_logger
puts "\nOK".color(:green)
end
......
......@@ -5,6 +5,10 @@ namespace :dev do
task setup: :environment do
ENV['force'] = 'yes'
Rake::Task["gitlab:setup"].invoke
# Make sure DB statistics are up to date.
ActiveRecord::Base.connection.execute('ANALYZE')
Rake::Task["gitlab:shell:setup"].invoke
end
......
......@@ -22,7 +22,7 @@ namespace :gitlab do
[project]
else
Project.find_each
Project.not_mass_generated.find_each
end
projects.each do |project|
......
......@@ -4386,6 +4386,9 @@ msgstr ""
msgid "Compare changes with the merge request target branch"
msgstr ""
msgid "Compare with previous version"
msgstr ""
msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
msgstr ""
......@@ -5683,6 +5686,9 @@ msgstr ""
msgid "Descending"
msgstr ""
msgid "Describe the goal of the changes and what reviewers should be aware of."
msgstr ""
msgid "Description"
msgstr ""
......@@ -10711,9 +10717,6 @@ msgstr ""
msgid "MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}"
msgstr ""
msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}"
msgstr ""
msgid "MergeRequest|Error dismissing suggestion popover. Please try again."
msgstr ""
......@@ -10858,6 +10861,9 @@ msgstr ""
msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
msgstr ""
msgid "Metrics|Validating query"
msgstr ""
msgid "Metrics|Y-axis label"
msgstr ""
......@@ -15926,6 +15932,9 @@ msgstr ""
msgid "Something went wrong while fetching comments. Please try again."
msgstr ""
msgid "Something went wrong while fetching description changes. Please try again."
msgstr ""
msgid "Something went wrong while fetching group member contributions"
msgstr ""
......@@ -21193,10 +21202,5 @@ msgstr ""
msgid "with %{additions} additions, %{deletions} deletions."
msgstr ""
msgid "within %d minute "
msgid_plural "within %d minutes "
msgstr[0] ""
msgstr[1] ""
msgid "yaml invalid"
msgstr ""
......@@ -10,9 +10,19 @@ module QA
element :impersonate_user_link
end
view 'app/views/admin/users/show.html.haml' do
element :confirm_user_button
end
def click_impersonate_user
click_element(:impersonate_user_link)
end
def confirm_user
accept_confirm do
click_element :confirm_user_button
end
end
end
end
end
......
......@@ -18,6 +18,10 @@ module QA
element :no_result_text, 'No groups or projects matched your search' # rubocop:disable QA/ElementWithPattern
end
view 'app/views/shared/members/_access_request_links.html.haml' do
element :leave_group_link
end
def click_subgroup(name)
click_link name
end
......@@ -42,6 +46,12 @@ module QA
click_element :new_in_group_button
end
def leave_group
accept_alert do
click_element :leave_group_link
end
end
private
def select_kind(kind)
......
......@@ -64,12 +64,11 @@ module QA
end
def visit!
Runtime::Logger.debug("Visiting #{web_url}")
Runtime::Logger.debug(%Q[Visiting #{self.class.name} at "#{web_url}"]) if Runtime::Env.debug?
Support::Retrier.retry_until do
visit(web_url)
wait { current_url == web_url }
wait { current_url.include?(URI.parse(web_url).path.split('/').last || web_url) }
end
end
......
......@@ -11,6 +11,10 @@ module QA
post Runtime::API::Request.new(api_client, api_members_path).url, { user_id: user.id, access_level: access_level }
end
def list_members
JSON.parse(get(Runtime::API::Request.new(api_client, api_members_path).url).body)
end
def api_members_path
"#{api_get_path}/members"
end
......
......@@ -7,6 +7,8 @@ module QA
# creating it if it doesn't yet exist.
#
class Sandbox < Base
include Members
attr_accessor :path
attribute :id
......
......@@ -57,13 +57,13 @@ module QA
Capybara.register_driver QA::Runtime::Env.browser do |app|
capabilities = Selenium::WebDriver::Remote::Capabilities.send(QA::Runtime::Env.browser,
# This enables access to logs with `page.driver.manage.get_log(:browser)`
loggingPrefs: {
browser: "ALL",
client: "ALL",
driver: "ALL",
server: "ALL"
})
# This enables access to logs with `page.driver.manage.get_log(:browser)`
loggingPrefs: {
browser: "ALL",
client: "ALL",
driver: "ALL",
server: "ALL"
})
if QA::Runtime::Env.accept_insecure_certs?
capabilities['acceptInsecureCerts'] = true
......
......@@ -19,6 +19,28 @@ module QA
set_feature(key, false)
end
def remove(key)
request = Runtime::API::Request.new(api_client, "/features/#{key}")
response = delete(request.url)
unless response.code == QA::Support::Api::HTTP_STATUS_NO_CONTENT
raise SetFeatureError, "Deleting feature flag #{key} failed with `#{response}`."
end
end
def enable_and_verify(key)
Support::Retrier.retry_on_exception(sleep_interval: 2) do
enable(key)
is_enabled = false
QA::Support::Waiter.wait(interval: 1) do
is_enabled = enabled?(key)
end
raise SetFeatureError, "#{key} was not enabled!" unless is_enabled
end
end
def enabled?(key)
feature = JSON.parse(get_features).find { |flag| flag["name"] == key }
feature && feature["state"] == "on"
......
......@@ -8,7 +8,9 @@ module QA
Page::Main::Login.perform(&:sign_in_with_saml)
Vendor::SAMLIdp::Page::Login.perform(&:login)
Vendor::SAMLIdp::Page::Login.perform do |login_page|
login_page.login('user1', 'user1pass')
end
expect(page).to have_content('Welcome to GitLab')
end
......
......@@ -7,18 +7,22 @@ module QA
module SAMLIdp
module Page
class Login < Page::Base
def login
fill_in 'username', with: 'user1'
fill_in 'password', with: 'user1pass'
def login(username, password)
QA::Runtime::Logger.debug("Logging into SAMLIdp with username: #{username} and password:#{password}") if QA::Runtime::Env.debug?
fill_in 'username', with: username
fill_in 'password', with: password
click_on 'Login'
end
def login_if_required
login if login_required?
def login_if_required(username, password)
login(username, password) if login_required?
end
def login_required?
page.has_text?('Enter your username and password')
login_required = page.has_text?('Enter your username and password')
QA::Runtime::Logger.debug("login_required: #{login_required}") if QA::Runtime::Env.debug?
login_required
end
end
end
......
......@@ -20,7 +20,7 @@ RSpec.configure do |config|
QA::Specs::Helpers::Quarantine.configure_rspec
config.before do |example|
QA::Runtime::Logger.debug("Starting test: #{example.full_description}") if QA::Runtime::Env.debug?
QA::Runtime::Logger.debug("\nStarting test: #{example.full_description}\n") if QA::Runtime::Env.debug?
end
config.after(:context) do
......
......@@ -25,6 +25,11 @@ describe "User creates a merge request", :js do
click_button("Compare branches")
page.within('.merge-request-form') do
expect(page.find('#merge_request_title')['placeholder']).to eq 'Title'
expect(page.find('#merge_request_description')['placeholder']).to eq 'Describe the goal of the changes and what reviewers should be aware of.'
end
fill_in("Title", with: title)
click_button("Submit merge request")
......
# 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', () => {
expect(result.name).toEqual('brpop, brpop');
});
it('supports hyphenated template variables', () => {
const config = { ...defaultConfig, name: 'expired - {{ test-attribute }}' };
const [result] = monitorHelper.makeDataSeries(
[{ metric: { 'test-attribute': 'test-attribute-value' }, values: series }],
config,
);
expect(result.name).toEqual('expired - test-attribute-value');
});
it('updates multiple series names from templates', () => {
const config = {
...defaultConfig,
......
......@@ -1094,8 +1094,9 @@ export const collapsedSystemNotes = [
noteable_type: 'Issue',
resolvable: false,
noteable_iid: 12,
start_description_version_id: undefined,
note: 'changed the description',
note_html: ' <p dir="auto">changed the description 2 times within 1 minute </p>',
note_html: '<p dir="auto">changed the description</p>',
current_user: { can_edit: false, can_award_emoji: true },
resolved: false,
resolved_by: null,
......@@ -1106,7 +1107,6 @@ export const collapsedSystemNotes = [
'/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_905&user_id=1',
human_access: 'Owner',
path: '/gitlab-org/gitlab-shell/notes/905',
times_updated: 2,
},
],
individual_note: true,
......
import Vue from 'vue';
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import { repoPropsData } from '../mock_data';
import createFlash from '~/flash';
import Tracking from '~/tracking';
import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import * as getters from '~/registry/stores/getters';
import { repoPropsData } from '../mock_data';
jest.mock('~/flash.js');
......@@ -16,9 +17,10 @@ describe('collapsible registry container', () => {
let wrapper;
let store;
const findDeleteBtn = w => w.find('.js-remove-repo');
const findContainerImageTags = w => w.find('.container-image-tags');
const findToggleRepos = w => w.findAll('.js-toggle-repo');
const findDeleteBtn = (w = wrapper) => w.find('.js-remove-repo');
const findContainerImageTags = (w = wrapper) => w.find('.container-image-tags');
const findToggleRepos = (w = wrapper) => w.findAll('.js-toggle-repo');
const findDeleteModal = (w = wrapper) => w.find({ ref: 'deleteModal' });
const mountWithStore = config => mount(collapsibleComponent, { ...config, store, localVue });
......@@ -124,4 +126,45 @@ describe('collapsible registry container', () => {
expect(deleteBtn.exists()).toBe(false);
});
});
describe('tracking', () => {
const category = 'mock_page';
beforeEach(() => {
jest.spyOn(Tracking, 'event');
wrapper.vm.deleteItem = jest.fn().mockResolvedValue();
wrapper.vm.fetchRepos = jest.fn();
wrapper.setData({
tracking: {
...wrapper.vm.tracking,
category,
},
});
});
it('send an event when delete button is clicked', () => {
const deleteBtn = findDeleteBtn();
deleteBtn.trigger('click');
expect(Tracking.event).toHaveBeenCalledWith(category, 'click_button', {
label: 'registry_repository_delete',
category,
});
});
it('send an event when cancel is pressed on modal', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('cancel');
expect(Tracking.event).toHaveBeenCalledWith(category, 'cancel_delete', {
label: 'registry_repository_delete',
category,
});
});
it('send an event when confirm is clicked on modal', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('ok');
expect(Tracking.event).toHaveBeenCalledWith(category, 'confirm_delete', {
label: 'registry_repository_delete',
category,
});
});
});
});
import Vue from 'vue';
import Vuex from 'vuex';
import tableRegistry from '~/registry/components/table_registry.vue';
import { mount, createLocalVue } from '@vue/test-utils';
import createFlash from '~/flash';
import Tracking from '~/tracking';
import tableRegistry from '~/registry/components/table_registry.vue';
import { repoPropsData } from '../mock_data';
import * as getters from '~/registry/stores/getters';
jest.mock('~/flash');
const [firstImage, secondImage] = repoPropsData.list;
const localVue = createLocalVue();
......@@ -15,11 +19,12 @@ describe('table registry', () => {
let wrapper;
let store;
const findSelectAllCheckbox = w => w.find('.js-select-all-checkbox > input');
const findSelectCheckboxes = w => w.findAll('.js-select-checkbox > input');
const findDeleteButton = w => w.find('.js-delete-registry');
const findDeleteButtonsRow = w => w.findAll('.js-delete-registry-row');
const findPagination = w => w.find('.js-registry-pagination');
const findSelectAllCheckbox = (w = wrapper) => w.find('.js-select-all-checkbox > input');
const findSelectCheckboxes = (w = wrapper) => w.findAll('.js-select-checkbox > input');
const findDeleteButton = (w = wrapper) => w.find({ ref: 'bulkDeleteButton' });
const findDeleteButtonsRow = (w = wrapper) => w.findAll('.js-delete-registry-row');
const findPagination = (w = wrapper) => w.find('.js-registry-pagination');
const findDeleteModal = (w = wrapper) => w.find({ ref: 'deleteModal' });
const bulkDeletePath = 'path';
const mountWithStore = config => mount(tableRegistry, { ...config, store, localVue });
......@@ -139,7 +144,7 @@ describe('table registry', () => {
},
});
wrapper.vm.handleMultipleDelete();
expect(wrapper.vm.showError).toHaveBeenCalled();
expect(createFlash).toHaveBeenCalled();
});
});
......@@ -169,6 +174,27 @@ describe('table registry', () => {
});
});
describe('modal event handlers', () => {
beforeEach(() => {
wrapper.vm.handleSingleDelete = jest.fn();
wrapper.vm.handleMultipleDelete = jest.fn();
});
it('on ok when one item is selected should call singleDelete', () => {
wrapper.setData({ itemsToBeDeleted: [0] });
wrapper.vm.onDeletionConfirmed();
expect(wrapper.vm.handleSingleDelete).toHaveBeenCalledWith(repoPropsData.list[0]);
expect(wrapper.vm.handleMultipleDelete).not.toHaveBeenCalled();
});
it('on ok when multiple items are selected should call muultiDelete', () => {
wrapper.setData({ itemsToBeDeleted: [0, 1, 2] });
wrapper.vm.onDeletionConfirmed();
expect(wrapper.vm.handleMultipleDelete).toHaveBeenCalled();
expect(wrapper.vm.handleSingleDelete).not.toHaveBeenCalled();
});
});
describe('pagination', () => {
const repo = {
repoPropsData,
......@@ -265,4 +291,83 @@ describe('table registry', () => {
expect(deleteBtns.length).toBe(0);
});
});
describe('event tracking', () => {
const mockPageName = 'mock_page';
beforeEach(() => {
jest.spyOn(Tracking, 'event');
wrapper.vm.handleSingleDelete = jest.fn();
wrapper.vm.handleMultipleDelete = jest.fn();
document.body.dataset.page = mockPageName;
});
afterEach(() => {
document.body.dataset.page = null;
});
describe('single tag delete', () => {
beforeEach(() => {
wrapper.setData({ itemsToBeDeleted: [0] });
});
it('send an event when delete button is clicked', () => {
const deleteBtn = findDeleteButtonsRow();
deleteBtn.at(0).trigger('click');
expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'click_button', {
label: 'registry_tag_delete',
property: 'foo',
});
});
it('send an event when cancel is pressed on modal', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('cancel');
expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'cancel_delete', {
label: 'registry_tag_delete',
property: 'foo',
});
});
it('send an event when confirm is clicked on modal', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('ok');
expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'confirm_delete', {
label: 'registry_tag_delete',
property: 'foo',
});
});
});
describe('bulk tag delete', () => {
beforeEach(() => {
const items = [0, 1, 2];
wrapper.setData({ itemsToBeDeleted: items, selectedItems: items });
});
it('send an event when delete button is clicked', () => {
const deleteBtn = findDeleteButton();
deleteBtn.vm.$emit('click');
expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'click_button', {
label: 'bulk_registry_tag_delete',
property: 'foo',
});
});
it('send an event when cancel is pressed on modal', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('cancel');
expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'cancel_delete', {
label: 'bulk_registry_tag_delete',
property: 'foo',
});
});
it('send an event when confirm is clicked on modal', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('ok');
expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'confirm_delete', {
label: 'bulk_registry_tag_delete',
property: 'foo',
});
});
});
});
});
......@@ -57,7 +57,7 @@ describe('system note component', () => {
// we need to strip them because they break layout of commit lists in system notes:
// https://gitlab.com/gitlab-org/gitlab-foss/uploads/b07a10670919254f0220d3ff5c1aa110/jqzI.png
it('removes wrapping paragraph from note HTML', () => {
expect(vm.$el.querySelector('.system-note-message').innerHTML).toEqual('<span>closed</span>');
expect(vm.$el.querySelector('.system-note-message').innerHTML).toContain('<span>closed</span>');
});
it('should initMRPopovers onMount', () => {
......
/* eslint-disable no-var */
import $ from 'jquery';
import '~/commons/bootstrap';
......@@ -10,15 +8,13 @@ describe('Bootstrap jQuery extensions', function() {
});
it('adds the disabled attribute', function() {
var $input;
$input = $('input').first();
const $input = $('input').first();
$input.disable();
expect($input).toHaveAttr('disabled', 'disabled');
});
return it('adds the disabled class', function() {
var $input;
$input = $('input').first();
const $input = $('input').first();
$input.disable();
expect($input).toHaveClass('disabled');
......@@ -30,15 +26,13 @@ describe('Bootstrap jQuery extensions', function() {
});
it('removes the disabled attribute', function() {
var $input;
$input = $('input').first();
const $input = $('input').first();
$input.enable();
expect($input).not.toHaveAttr('disabled');
});
return it('removes the disabled class', function() {
var $input;
$input = $('input').first();
const $input = $('input').first();
$input.enable();
expect($input).not.toHaveClass('disabled');
......
......@@ -122,6 +122,32 @@ describe('Dashboard', () => {
});
});
describe('cluster health', () => {
let wrapper;
beforeEach(done => {
wrapper = shallowMount(DashboardComponent, {
localVue,
sync: false,
propsData: { ...propsData, hasMetrics: true },
store,
});
// all_dashboards is not defined in health dashboards
wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined);
wrapper.vm.$nextTick(done);
});
afterEach(() => {
wrapper.destroy();
});
it('renders correctly', () => {
expect(wrapper.isVueInstance()).toBe(true);
expect(wrapper.exists()).toBe(true);
});
});
describe('requests information to the server', () => {
let spy;
beforeEach(() => {
......
......@@ -144,7 +144,19 @@ describe('Monitoring mutations', () => {
});
describe('SET_ALL_DASHBOARDS', () => {
it('stores the dashboards loaded from the git repository', () => {
it('stores `undefined` dashboards as an empty array', () => {
mutations[types.SET_ALL_DASHBOARDS](stateCopy, undefined);
expect(stateCopy.allDashboards).toEqual([]);
});
it('stores `null` dashboards as an empty array', () => {
mutations[types.SET_ALL_DASHBOARDS](stateCopy, null);
expect(stateCopy.allDashboards).toEqual([]);
});
it('stores dashboards loaded from the git repository', () => {
mutations[types.SET_ALL_DASHBOARDS](stateCopy, dashboardGitResponse);
expect(stateCopy.allDashboards).toEqual(dashboardGitResponse);
......
import {
isDescriptionSystemNote,
changeDescriptionNote,
getTimeDifferenceMinutes,
collapseSystemNotes,
} from '~/notes/stores/collapse_utils';
......@@ -24,15 +23,6 @@ describe('Collapse utils', () => {
);
});
it('changes the description to contain the number of changed times', () => {
const changedNote = changeDescriptionNote(mockSystemNote, 3, 5);
expect(changedNote.times_updated).toEqual(3);
expect(changedNote.note_html.trim()).toContain(
'<p dir="auto">changed the description 3 times within 5 minutes </p>',
);
});
it('gets the time difference between two notes', () => {
const anotherSystemNote = {
created_at: '2018-05-14T21:33:00.000Z',
......
# 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
end
describe '#paged_nodes' do
let!(:projects) { create_list(:project, 5) }
let_it_be(:all_nodes) { create_list(:project, 5) }
let(:paged_nodes) { subject.paged_nodes }
it 'returns the collection limited to max page size' do
expect(subject.paged_nodes.size).to eq(3)
end
it 'is a loaded memoized array' do
expect(subject.paged_nodes).to be_an(Array)
expect(subject.paged_nodes.object_id).to eq(subject.paged_nodes.object_id)
end
context 'when `first` is passed' do
let(:arguments) { { first: 2 } }
it 'returns only the first elements' do
expect(subject.paged_nodes).to contain_exactly(projects.first, projects.second)
end
end
context 'when `last` is passed' do
let(:arguments) { { last: 2 } }
it 'returns only the last elements' do
expect(subject.paged_nodes).to contain_exactly(projects[3], projects[4])
end
end
it_behaves_like "connection with paged nodes"
context 'when both are passed' do
let(:arguments) { { first: 2, last: 2 } }
it 'raises an error' do
expect { subject.paged_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
expect { paged_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
end
end
......
......@@ -13,14 +13,19 @@ describe Gitlab::Prometheus::Queries::KnativeInvocationQuery do
context 'verify queries' do
before do
allow(PrometheusMetric).to receive(:find_by_identifier).and_return(create(:prometheus_metric, query: prometheus_istio_query('test-name', 'test-ns')))
allow(client).to receive(:query_range)
create(:prometheus_metric,
:common,
identifier: :system_metrics_knative_function_invocation_count,
query: 'sum(ceil(rate(istio_requests_total{destination_service_namespace="%{kube_namespace}", destination_app=~"%{function_name}.*"}[1m])*60))')
end
it 'has the query, but no data' do
results = subject.query(serverless_func.id)
expect(client).to receive(:query_range).with(
'sum(ceil(rate(istio_requests_total{destination_service_namespace="test-ns", destination_app=~"test-name.*"}[1m])*60))',
hash_including(:start, :stop)
)
expect(results.queries[0][:query_range]).to eql('floor(sum(rate(istio_revision_request_count{destination_configuration="test-name", destination_namespace="test-ns"}[1m])*30))')
subject.query(serverless_func.id)
end
end
end
......@@ -37,9 +37,12 @@ module GraphqlHelpers
# BatchLoader::GraphQL returns a wrapper, so we need to :sync in order
# to get the actual values
def batch_sync(max_queries: nil, &blk)
result = batch(max_queries: nil, &blk)
wrapper = proc do
lazy_vals = yield
lazy_vals.is_a?(Array) ? lazy_vals.map(&:sync) : lazy_vals&.sync
end
result.is_a?(Array) ? result.map(&:sync) : result&.sync
batch(max_queries: max_queries, &wrapper)
end
def graphql_query_for(name, attributes = {}, fields = nil)
......@@ -157,7 +160,13 @@ module GraphqlHelpers
def attributes_to_graphql(attributes)
attributes.map do |name, value|
"#{GraphqlHelpers.fieldnamerize(name.to_s)}: \"#{value}\""
value_str = if value.is_a?(Array)
'["' + value.join('","') + '"]'
else
"\"#{value}\""
end
"#{GraphqlHelpers.fieldnamerize(name.to_s)}: #{value_str}"
end.join(", ")
end
......@@ -282,6 +291,12 @@ module GraphqlHelpers
def allow_high_graphql_recursion
allow_any_instance_of(Gitlab::Graphql::QueryAnalyzers::RecursionAnalyzer).to receive(:recursion_threshold).and_return 1000
end
def node_array(data, extract_attribute = nil)
data.map do |item|
extract_attribute ? item['node'][extract_attribute] : item['node']
end
end
end
# This warms our schema, doing this as part of loading the helpers to avoid
......
# 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