Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2019-11-15 12:06:12 +00:00
parent e24153b0cb
commit 3fc9a8e695
97 changed files with 1433 additions and 546 deletions

1
.gitignore vendored
View File

@ -82,3 +82,4 @@ jsdoc/
**/tmp/rubocop_cache/** **/tmp/rubocop_cache/**
.overcommit.yml .overcommit.yml
.projections.json .projections.json
/qa/.rakeTasks

View File

@ -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'

View File

@ -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
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)

View File

@ -1,11 +1,9 @@
/* 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) =>
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 };

View File

@ -1,4 +1,4 @@
/* 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 {
'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 {
} }
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 }) => {

View File

@ -1,4 +1,4 @@
/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, one-var, no-new, consistent-return, no-shadow, no-param-reassign, vars-on-top, no-lonely-if, no-else-return, dot-notation, no-empty */ /* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-else-return, dot-notation, no-empty */
/* global Issuable */ /* global Issuable */
/* global ListLabel */ /* global ListLabel */
@ -15,63 +15,39 @@ import { isScopedLabel } from '~/lib/utils/common_utils';
export default class LabelsSelect { export default class LabelsSelect {
constructor(els, options = {}) { constructor(els, options = {}) {
var _this, $els; const _this = this;
_this = this;
$els = $(els); let $els = $(els);
if (!els) { if (!els) {
$els = $('.js-label-select'); $els = $('.js-label-select');
} }
$els.each((i, dropdown) => { $els.each((i, dropdown) => {
var $block, const $dropdown = $(dropdown);
$dropdown, const $dropdownContainer = $dropdown.closest('.labels-filter');
$form, const namespacePath = $dropdown.data('namespacePath');
$loading, const projectPath = $dropdown.data('projectPath');
$selectbox, const issueUpdateURL = $dropdown.data('issueUpdate');
$sidebarCollapsedValue, let selectedLabel = $dropdown.data('selected');
$value,
$dropdownMenu,
abilityName,
defaultLabel,
issueUpdateURL,
labelUrl,
namespacePath,
projectPath,
saveLabelData,
selectedLabel,
showAny,
showNo,
$sidebarLabelTooltip,
initialSelected,
fieldName,
showMenuAbove,
$dropdownContainer;
$dropdown = $(dropdown);
$dropdownContainer = $dropdown.closest('.labels-filter');
namespacePath = $dropdown.data('namespacePath');
projectPath = $dropdown.data('projectPath');
issueUpdateURL = $dropdown.data('issueUpdate');
selectedLabel = $dropdown.data('selected');
if (selectedLabel != null && !$dropdown.hasClass('js-multiselect')) { if (selectedLabel != null && !$dropdown.hasClass('js-multiselect')) {
selectedLabel = selectedLabel.split(','); selectedLabel = selectedLabel.split(',');
} }
showNo = $dropdown.data('showNo'); const showNo = $dropdown.data('showNo');
showAny = $dropdown.data('showAny'); const showAny = $dropdown.data('showAny');
showMenuAbove = $dropdown.data('showMenuAbove'); const showMenuAbove = $dropdown.data('showMenuAbove');
defaultLabel = $dropdown.data('defaultLabel') || __('Label'); const defaultLabel = $dropdown.data('defaultLabel') || __('Label');
abilityName = $dropdown.data('abilityName'); const abilityName = $dropdown.data('abilityName');
$selectbox = $dropdown.closest('.selectbox'); const $selectbox = $dropdown.closest('.selectbox');
$block = $selectbox.closest('.block'); const $block = $selectbox.closest('.block');
$form = $dropdown.closest('form, .js-issuable-update'); const $form = $dropdown.closest('form, .js-issuable-update');
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
$sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); const $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
$value = $block.find('.value'); const $value = $block.find('.value');
$dropdownMenu = $dropdown.parent().find('.dropdown-menu'); const $dropdownMenu = $dropdown.parent().find('.dropdown-menu');
$loading = $block.find('.block-loading').fadeOut(); const $loading = $block.find('.block-loading').fadeOut();
fieldName = $dropdown.data('fieldName'); const fieldName = $dropdown.data('fieldName');
initialSelected = $selectbox let initialSelected = $selectbox
.find(`input[name="${$dropdown.data('fieldName')}"]`) .find(`input[name="${$dropdown.data('fieldName')}"]`)
.map(function() { .map(function() {
return this.value; return this.value;
@ -90,9 +66,8 @@ export default class LabelsSelect {
); );
} }
saveLabelData = function() { const saveLabelData = function() {
var data, selected; const selected = $dropdown
selected = $dropdown
.closest('.selectbox') .closest('.selectbox')
.find(`input[name='${fieldName}']`) .find(`input[name='${fieldName}']`)
.map(function() { .map(function() {
@ -103,7 +78,7 @@ export default class LabelsSelect {
if (_.isEqual(initialSelected, selected)) return; if (_.isEqual(initialSelected, selected)) return;
initialSelected = selected; initialSelected = selected;
data = {}; const data = {};
data[abilityName] = {}; data[abilityName] = {};
data[abilityName].label_ids = selected; data[abilityName].label_ids = selected;
if (!selected.length) { if (!selected.length) {
@ -114,12 +89,13 @@ export default class LabelsSelect {
axios axios
.put(issueUpdateURL, data) .put(issueUpdateURL, data)
.then(({ data }) => { .then(({ data }) => {
var labelCount, template, labelTooltipTitle, labelTitles; let labelTooltipTitle;
let template;
$loading.fadeOut(); $loading.fadeOut();
$dropdown.trigger('loaded.gl.dropdown'); $dropdown.trigger('loaded.gl.dropdown');
$selectbox.hide(); $selectbox.hide();
data.issueUpdateURL = issueUpdateURL; data.issueUpdateURL = issueUpdateURL;
labelCount = 0; let labelCount = 0;
if (data.labels.length && issueUpdateURL) { if (data.labels.length && issueUpdateURL) {
template = LabelsSelect.getLabelTemplate({ template = LabelsSelect.getLabelTemplate({
labels: _.sortBy(data.labels, 'title'), labels: _.sortBy(data.labels, 'title'),
@ -174,7 +150,7 @@ export default class LabelsSelect {
$sidebarCollapsedValue.text(labelCount); $sidebarCollapsedValue.text(labelCount);
if (data.labels.length) { if (data.labels.length) {
labelTitles = data.labels.map(label => label.title); let labelTitles = data.labels.map(label => label.title);
if (labelTitles.length > 5) { if (labelTitles.length > 5) {
labelTitles = labelTitles.slice(0, 5); labelTitles = labelTitles.slice(0, 5);
@ -199,13 +175,13 @@ export default class LabelsSelect {
$dropdown.glDropdown({ $dropdown.glDropdown({
showMenuAbove, showMenuAbove,
data(term, callback) { data(term, callback) {
labelUrl = $dropdown.attr('data-labels'); const labelUrl = $dropdown.attr('data-labels');
axios axios
.get(labelUrl) .get(labelUrl)
.then(res => { .then(res => {
let { data } = res; let { data } = res;
if ($dropdown.hasClass('js-extra-options')) { if ($dropdown.hasClass('js-extra-options')) {
var extraData = []; const extraData = [];
if (showNo) { if (showNo) {
extraData.unshift({ extraData.unshift({
id: 0, id: 0,
@ -232,22 +208,14 @@ export default class LabelsSelect {
.catch(() => flash(__('Error fetching labels.'))); .catch(() => flash(__('Error fetching labels.')));
}, },
renderRow(label) { renderRow(label) {
var linkEl, let colorEl;
listItemEl,
colorEl,
indeterminate,
removesAll,
selectedClass,
i,
marked,
dropdownValue;
selectedClass = []; const selectedClass = [];
removesAll = label.id <= 0 || label.id == null; const removesAll = label.id <= 0 || label.id == null;
if ($dropdown.hasClass('js-filter-bulk-update')) { if ($dropdown.hasClass('js-filter-bulk-update')) {
indeterminate = $dropdown.data('indeterminate') || []; const indeterminate = $dropdown.data('indeterminate') || [];
marked = $dropdown.data('marked') || []; const marked = $dropdown.data('marked') || [];
if (indeterminate.indexOf(label.id) !== -1) { if (indeterminate.indexOf(label.id) !== -1) {
selectedClass.push('is-indeterminate'); selectedClass.push('is-indeterminate');
@ -255,7 +223,7 @@ export default class LabelsSelect {
if (marked.indexOf(label.id) !== -1) { if (marked.indexOf(label.id) !== -1) {
// Remove is-indeterminate class if the item will be marked as active // Remove is-indeterminate class if the item will be marked as active
i = selectedClass.indexOf('is-indeterminate'); const i = selectedClass.indexOf('is-indeterminate');
if (i !== -1) { if (i !== -1) {
selectedClass.splice(i, 1); selectedClass.splice(i, 1);
} }
@ -263,7 +231,7 @@ export default class LabelsSelect {
} }
} else { } else {
if (this.id(label)) { if (this.id(label)) {
dropdownValue = this.id(label) const dropdownValue = this.id(label)
.toString() .toString()
.replace(/'/g, "\\'"); .replace(/'/g, "\\'");
@ -287,7 +255,7 @@ export default class LabelsSelect {
colorEl = ''; colorEl = '';
} }
linkEl = document.createElement('a'); const linkEl = document.createElement('a');
linkEl.href = '#'; linkEl.href = '#';
// We need to identify which items are actually labels // We need to identify which items are actually labels
@ -300,7 +268,7 @@ export default class LabelsSelect {
linkEl.className = selectedClass.join(' '); linkEl.className = selectedClass.join(' ');
linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`; linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`;
listItemEl = document.createElement('li'); const listItemEl = document.createElement('li');
listItemEl.appendChild(linkEl); listItemEl.appendChild(linkEl);
return listItemEl; return listItemEl;
@ -312,12 +280,12 @@ export default class LabelsSelect {
filterable: true, filterable: true,
selected: $dropdown.data('selected') || [], selected: $dropdown.data('selected') || [],
toggleLabel(selected, el) { toggleLabel(selected, el) {
var $dropdownParent = $dropdown.parent(); const $dropdownParent = $dropdown.parent();
var $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); const $dropdownInputField = $dropdownParent.find('.dropdown-input-field');
var isSelected = el !== null ? el.hasClass('is-active') : false; const isSelected = el !== null ? el.hasClass('is-active') : false;
var title = selected ? selected.title : null; const title = selected ? selected.title : null;
var selectedLabels = this.selected; const selectedLabels = this.selected;
if ($dropdownInputField.length && $dropdownInputField.val().length) { if ($dropdownInputField.length && $dropdownInputField.val().length) {
$dropdownParent.find('.dropdown-input-clear').trigger('click'); $dropdownParent.find('.dropdown-input-clear').trigger('click');
@ -329,7 +297,7 @@ export default class LabelsSelect {
} else if (isSelected) { } else if (isSelected) {
this.selected.push(title); this.selected.push(title);
} else if (!isSelected && title) { } else if (!isSelected && title) {
var index = this.selected.indexOf(title); const index = this.selected.indexOf(title);
this.selected.splice(index, 1); this.selected.splice(index, 1);
} }
@ -359,10 +327,9 @@ export default class LabelsSelect {
} }
}, },
hidden() { hidden() {
var isIssueIndex, isMRIndex, page; const page = $('body').attr('data-page');
page = $('body').attr('data-page'); const isIssueIndex = page === 'projects:issues:index';
isIssueIndex = page === 'projects:issues:index'; const isMRIndex = page === 'projects:merge_requests:index';
isMRIndex = page === 'projects:merge_requests:index';
$selectbox.hide(); $selectbox.hide();
// display:block overrides the hide-collapse rule // display:block overrides the hide-collapse rule
$value.removeAttr('style'); $value.removeAttr('style');
@ -393,14 +360,13 @@ export default class LabelsSelect {
const { $el, e, isMarking } = clickEvent; const { $el, e, isMarking } = clickEvent;
const label = clickEvent.selectedObj; const label = clickEvent.selectedObj;
var isIssueIndex, isMRIndex, page, boardsModel; const fadeOutLoader = () => {
var fadeOutLoader = () => {
$loading.fadeOut(); $loading.fadeOut();
}; };
page = $('body').attr('data-page'); const page = $('body').attr('data-page');
isIssueIndex = page === 'projects:issues:index'; const isIssueIndex = page === 'projects:issues:index';
isMRIndex = page === 'projects:merge_requests:index'; const isMRIndex = page === 'projects:merge_requests:index';
if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) { if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) {
$dropdown $dropdown
@ -419,6 +385,7 @@ export default class LabelsSelect {
return; return;
} }
let boardsModel;
if ($dropdown.closest('.add-issues-modal').length) { if ($dropdown.closest('.add-issues-modal').length) {
boardsModel = ModalStore.store.filter; boardsModel = ModalStore.store.filter;
} }
@ -450,7 +417,7 @@ export default class LabelsSelect {
}), }),
); );
} else { } else {
var { labels } = boardsStore.detail.issue; let { labels } = boardsStore.detail.issue;
labels = labels.filter(selectedLabel => selectedLabel.id !== label.id); labels = labels.filter(selectedLabel => selectedLabel.id !== label.id);
boardsStore.detail.issue.labels = labels; boardsStore.detail.issue.labels = labels;
} }
@ -578,16 +545,14 @@ export default class LabelsSelect {
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
setDropdownData($dropdown, isMarking, value) { setDropdownData($dropdown, isMarking, value) {
var i, markedIds, unmarkedIds, indeterminateIds; const markedIds = $dropdown.data('marked') || [];
const unmarkedIds = $dropdown.data('unmarked') || [];
markedIds = $dropdown.data('marked') || []; const indeterminateIds = $dropdown.data('indeterminate') || [];
unmarkedIds = $dropdown.data('unmarked') || [];
indeterminateIds = $dropdown.data('indeterminate') || [];
if (isMarking) { if (isMarking) {
markedIds.push(value); markedIds.push(value);
i = indeterminateIds.indexOf(value); let i = indeterminateIds.indexOf(value);
if (i > -1) { if (i > -1) {
indeterminateIds.splice(i, 1); indeterminateIds.splice(i, 1);
} }
@ -598,7 +563,7 @@ export default class LabelsSelect {
} }
} else { } else {
// If marked item (not common) is unmarked // If marked item (not common) is unmarked
i = markedIds.indexOf(value); const i = markedIds.indexOf(value);
if (i > -1) { if (i > -1) {
markedIds.splice(i, 1); markedIds.splice(i, 1);
} }

View File

@ -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);

View File

@ -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;

View File

@ -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')"

View File

@ -0,0 +1,12 @@
// Placeholder for GitLab FOSS
// Actual implementation: ee/app/assets/javascripts/notes/mixins/description_version_history.js
export default {
computed: {
canSeeDescriptionVersion() {},
shouldShowDescriptionVersion() {},
descriptionVersionToggleIcon() {},
},
methods: {
toggleDescriptionVersion() {},
},
};

View File

@ -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) =>
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 () => {};

View File

@ -1,34 +1,9 @@
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
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 => {
} 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;
}, []); }, []);

View File

@ -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 {
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 {
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 {
} }
}, },
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 {
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 {
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="

View File

@ -1,20 +1,15 @@
<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 {
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
}, },
mixins: [timeagoMixin], mixins: [timeagoMixin],
props: { props: {
@ -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 {
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 {
} 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 {
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 {
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 {
<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 {
<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 {
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>

View File

@ -1,15 +1,8 @@
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.'),
};

View File

@ -1,7 +1,7 @@
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 }) => {
}) })
.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 }) => {
}) })
.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);
}); });
}; };

View File

@ -1,33 +1,31 @@
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 }) {

View File

@ -1,4 +1,4 @@
/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, class-methods-use-this */ /* eslint-disable func-names, consistent-return, one-var, no-else-return, class-methods-use-this */
import $ from 'jquery'; import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility'; import { visitUrl } from './lib/utils/url_utility';
@ -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 {
} }
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;
} }

View File

@ -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 {
Icon, Icon,
noteHeader, noteHeader,
TimelineEntryItem, TimelineEntryItem,
GlSkeletonLoading,
}, },
mixins: [descriptionVersionHistoryMixin],
props: { props: {
note: { note: {
type: Object, type: Object,
@ -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 {
<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 {
<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>

View File

@ -1,7 +1,7 @@
$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);
.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 {

View File

@ -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;

View File

@ -0,0 +1,129 @@
# frozen_string_literal: true
class PrometheusMetricsFinder
ACCEPTED_PARAMS = [
:project,
:group,
:title,
:y_label,
:identifier,
:id,
:common,
:ordered
].freeze
# Cautiously preferring a memoized class method over a constant
# so that the DB connection is accessed after the class is loaded.
def self.indexes
@indexes ||= PrometheusMetric
.connection
.indexes(:prometheus_metrics)
.map { |index| index.columns.map(&:to_sym) }
end
def initialize(params = {})
@params = params.slice(*ACCEPTED_PARAMS)
end
# @return [PrometheusMetric, PrometheusMetric::ActiveRecord_Relation]
def execute
validate_params!
metrics = by_project(::PrometheusMetric.all)
metrics = by_group(metrics)
metrics = by_title(metrics)
metrics = by_y_label(metrics)
metrics = by_common(metrics)
metrics = by_ordered(metrics)
metrics = by_identifier(metrics)
metrics = by_id(metrics)
metrics
end
private
attr_reader :params
def by_project(metrics)
return metrics unless params[:project]
metrics.for_project(params[:project])
end
def by_group(metrics)
return metrics unless params[:group]
metrics.for_group(params[:group])
end
def by_title(metrics)
return metrics unless params[:title]
metrics.for_title(params[:title])
end
def by_y_label(metrics)
return metrics unless params[:y_label]
metrics.for_y_label(params[:y_label])
end
def by_common(metrics)
return metrics unless params[:common]
metrics.common
end
def by_ordered(metrics)
return metrics unless params[:ordered]
metrics.ordered
end
def by_identifier(metrics)
return metrics unless params[:identifier]
metrics.for_identifier(params[:identifier])
end
def by_id(metrics)
return metrics unless params[:id]
metrics.id_in(params[:id])
end
def validate_params!
validate_params_present!
validate_id_params!
validate_indexes!
end
# Ensure all provided params are supported
def validate_params_present!
raise ArgumentError, "Please provide one or more of: #{ACCEPTED_PARAMS}" if params.blank?
end
# Protect against the caller "finding" the wrong metric
def validate_id_params!
raise ArgumentError, 'Only one of :identifier, :id is permitted' if params[:identifier] && params[:id]
raise ArgumentError, ':identifier must be scoped to a :project or :common' if params[:identifier] && !(params[:project] || params[:common])
end
# Protect against unaccounted-for, complex/slow queries.
# This is not a hard and fast rule, but is meant to encourage
# mindful inclusion of new queries.
def validate_indexes!
indexable_params = params.except(:ordered, :id, :project).keys
indexable_params << :project_id if params[:project]
indexable_params.sort!
raise ArgumentError, "An index should exist for params: #{indexable_params}" unless appropriate_index?(indexable_params)
end
def appropriate_index?(indexable_params)
return true if indexable_params.blank?
self.class.indexes.any? { |index| (index - indexable_params).empty? }
end
end

View File

@ -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

View File

@ -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)

View File

@ -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')

View File

@ -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.

View File

@ -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'

View File

@ -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 @@
= 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

View File

@ -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]),

View File

@ -0,0 +1,5 @@
---
title: Fix query validation in custom metrics form
merge_request: 18769
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Improve merge request description placeholder
merge_request: 20032
author: Jacopo Beschi @jacopo-beschi
type: changed

View File

@ -0,0 +1,5 @@
---
title: Add event tracking to container registry
merge_request: 19772
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Fix broken monitor cluster health dashboard
merge_request: 20120
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Move margin-top from flash container to flash
merge_request: 20211
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Remove var from bootstrap_jquery_spec.js
merge_request: 20089
author: Lee Tickett
type: other

View File

@ -0,0 +1,5 @@
---
title: Remove var from issue.js
merge_request: 20098
author: Lee Tickett
type: other

View File

@ -0,0 +1,5 @@
---
title: Remove var from labels_select.js
merge_request: 20153
author: Lee Tickett
type: other

View File

@ -0,0 +1,5 @@
---
title: Remove var from tree.js
merge_request: 20103
author: Lee Tickett
type: other

View File

@ -0,0 +1,77 @@
# frozen_string_literal: true
class Gitlab::Seeder::Users
include ActionView::Helpers::NumberHelper
RANDOM_USERS_COUNT = 20
MASS_USERS_COUNT = ENV['CI'] ? 10 : 1_000_000
MASS_INSERT_USERNAME_START = 'mass_insert_user_'
attr_reader :opts
def initialize(opts = {})
@opts = opts
end
def seed!
Sidekiq::Testing.inline! do
create_mass_users!
create_random_users!
end
end
private
def create_mass_users!
encrypted_password = Devise::Encryptor.digest(User, '12345678')
Gitlab::Seeder.with_mass_insert(MASS_USERS_COUNT, User) do
ActiveRecord::Base.connection.execute <<~SQL
INSERT INTO users (username, name, email, confirmed_at, projects_limit, encrypted_password)
SELECT
'#{MASS_INSERT_USERNAME_START}' || seq,
'Seed user ' || seq,
'seed_user' || seq || '@example.com',
to_timestamp(seq),
#{MASS_USERS_COUNT},
'#{encrypted_password}'
FROM generate_series(1, #{MASS_USERS_COUNT}) AS seq
SQL
end
relation = User.where(admin: false)
Gitlab::Seeder.with_mass_insert(relation.count, Namespace) do
ActiveRecord::Base.connection.execute <<~SQL
INSERT INTO namespaces (name, path, owner_id)
SELECT
username,
username,
id
FROM users WHERE NOT admin
SQL
end
end
def create_random_users!
RANDOM_USERS_COUNT.times do |i|
begin
User.create!(
username: FFaker::Internet.user_name,
name: FFaker::Name.name,
email: FFaker::Internet.email,
confirmed_at: DateTime.now,
password: '12345678'
)
print '.'
rescue ActiveRecord::RecordInvalid
print 'F'
end
end
end
end
Gitlab::Seeder.quiet do
users = Gitlab::Seeder::Users.new
users.seed!
end

View File

@ -1,137 +1,210 @@
require './spec/support/sidekiq' require './spec/support/sidekiq'
# rubocop:disable Rails/Output class Gitlab::Seeder::Projects
include ActionView::Helpers::NumberHelper
Sidekiq::Testing.inline! do PROJECT_URLS = %w[
Gitlab::Seeder.quiet do https://gitlab.com/gitlab-org/gitlab-test.git
Gitlab::Seeder.without_gitaly_timeout do https://gitlab.com/gitlab-org/gitlab-shell.git
project_urls = %w[ https://gitlab.com/gnuwget/wget2.git
https://gitlab.com/gitlab-org/gitlab-test.git https://gitlab.com/Commit451/LabCoat.git
https://gitlab.com/gitlab-org/gitlab-shell.git https://github.com/jashkenas/underscore.git
https://gitlab.com/gnuwget/wget2.git https://github.com/flightjs/flight.git
https://gitlab.com/Commit451/LabCoat.git https://github.com/twitter/typeahead.js.git
https://github.com/jashkenas/underscore.git https://github.com/h5bp/html5-boilerplate.git
https://github.com/flightjs/flight.git https://github.com/google/material-design-lite.git
https://github.com/twitter/typeahead.js.git https://github.com/jlevy/the-art-of-command-line.git
https://github.com/h5bp/html5-boilerplate.git https://github.com/FreeCodeCamp/freecodecamp.git
https://github.com/google/material-design-lite.git https://github.com/google/deepdream.git
https://github.com/jlevy/the-art-of-command-line.git https://github.com/jtleek/datasharing.git
https://github.com/FreeCodeCamp/freecodecamp.git https://github.com/WebAssembly/design.git
https://github.com/google/deepdream.git https://github.com/airbnb/javascript.git
https://github.com/jtleek/datasharing.git https://github.com/tessalt/echo-chamber-js.git
https://github.com/WebAssembly/design.git https://github.com/atom/atom.git
https://github.com/airbnb/javascript.git https://github.com/mattermost/mattermost-server.git
https://github.com/tessalt/echo-chamber-js.git https://github.com/purifycss/purifycss.git
https://github.com/atom/atom.git https://github.com/facebook/nuclide.git
https://github.com/mattermost/mattermost-server.git https://github.com/wbkd/awesome-d3.git
https://github.com/purifycss/purifycss.git https://github.com/kilimchoi/engineering-blogs.git
https://github.com/facebook/nuclide.git https://github.com/gilbarbara/logos.git
https://github.com/wbkd/awesome-d3.git https://github.com/reduxjs/redux.git
https://github.com/kilimchoi/engineering-blogs.git https://github.com/awslabs/s2n.git
https://github.com/gilbarbara/logos.git https://github.com/arkency/reactjs_koans.git
https://github.com/reduxjs/redux.git https://github.com/twbs/bootstrap.git
https://github.com/awslabs/s2n.git https://github.com/chjj/ttystudio.git
https://github.com/arkency/reactjs_koans.git https://github.com/MostlyAdequate/mostly-adequate-guide.git
https://github.com/twbs/bootstrap.git https://github.com/octocat/Spoon-Knife.git
https://github.com/chjj/ttystudio.git https://github.com/opencontainers/runc.git
https://github.com/MostlyAdequate/mostly-adequate-guide.git https://github.com/googlesamples/android-topeka.git
https://github.com/octocat/Spoon-Knife.git ]
https://github.com/opencontainers/runc.git LARGE_PROJECT_URLS = %w[
https://github.com/googlesamples/android-topeka.git https://github.com/torvalds/linux.git
] https://gitlab.gnome.org/GNOME/gimp.git
https://gitlab.gnome.org/GNOME/gnome-mud.git
https://gitlab.com/fdroid/fdroidclient.git
https://gitlab.com/inkscape/inkscape.git
https://github.com/gnachman/iTerm2.git
]
# Consider altering MASS_USERS_COUNT for less
# users with projects.
MASS_PROJECTS_COUNT_PER_USER = {
private: 3, # 3m projects +
internal: 1, # 1m projects +
public: 1 # 1m projects = 5m total
}
MASS_INSERT_NAME_START = 'mass_insert_project_'
large_project_urls = %w[ def seed!
https://github.com/torvalds/linux.git Sidekiq::Testing.inline! do
https://gitlab.gnome.org/GNOME/gimp.git create_real_projects!
https://gitlab.gnome.org/GNOME/gnome-mud.git create_large_projects!
https://gitlab.com/fdroid/fdroidclient.git create_mass_projects!
https://gitlab.com/inkscape/inkscape.git end
https://github.com/gnachman/iTerm2.git end
]
def create_project(url, force_latest_storage: false) private
group_path, project_path = url.split('/')[-2..-1]
group = Group.find_by(path: group_path) def create_real_projects!
# You can specify how many projects you need during seed execution
size = ENV['SIZE'].present? ? ENV['SIZE'].to_i : 8
unless group PROJECT_URLS.first(size).each_with_index do |url, i|
group = Group.new( create_real_project!(url, force_latest_storage: i.even?)
name: group_path.titleize, end
path: group_path end
)
group.description = FFaker::Lorem.sentence
group.save!
group.add_owner(User.first) def create_large_projects!
end return unless ENV['LARGE_PROJECTS'].present?
project_path.gsub!(".git", "") LARGE_PROJECT_URLS.each(&method(:create_real_project!))
params = { if ENV['FORK'].present?
import_url: url, puts "\nGenerating forks"
namespace_id: group.id,
name: project_path.titleize,
description: FFaker::Lorem.sentence,
visibility_level: Gitlab::VisibilityLevel.values.sample,
skip_disk_validation: true
}
if force_latest_storage project_name = ENV['FORK'] == 'true' ? 'torvalds/linux' : ENV['FORK']
params[:storage_version] = Project::LATEST_STORAGE_VERSION
end
project = nil project = Project.find_by_full_path(project_name)
Sidekiq::Worker.skipping_transaction_check do User.offset(1).first(5).each do |user|
project = Projects::CreateService.new(User.first, params).execute new_project = ::Projects::ForkService.new(project, user).execute
# Seed-Fu runs this entire fixture in a transaction, so the `after_commit` if new_project.valid? && (new_project.valid_repo? || new_project.import_state.scheduled?)
# hook won't run until after the fixture is loaded. That is too late
# since the Sidekiq::Testing block has already exited. Force clearing
# the `after_commit` queue to ensure the job is run now.
project.send(:_run_after_commit_queue)
project.import_state.send(:_run_after_commit_queue)
end
if project.valid? && project.valid_repo?
print '.' print '.'
else else
puts project.errors.full_messages new_project.errors.full_messages.each do |error|
print 'F' puts "#{new_project.full_path}: #{error}"
end
end
# You can specify how many projects you need during seed execution
size = ENV['SIZE'].present? ? ENV['SIZE'].to_i : 8
project_urls.first(size).each_with_index do |url, i|
create_project(url, force_latest_storage: i.even?)
end
if ENV['LARGE_PROJECTS'].present?
large_project_urls.each(&method(:create_project))
if ENV['FORK'].present?
puts "\nGenerating forks"
project_name = ENV['FORK'] == 'true' ? 'torvalds/linux' : ENV['FORK']
project = Project.find_by_full_path(project_name)
User.offset(1).first(5).each do |user|
new_project = Projects::ForkService.new(project, user).execute
if new_project.valid? && (new_project.valid_repo? || new_project.import_state.scheduled?)
print '.'
else
new_project.errors.full_messages.each do |error|
puts "#{new_project.full_path}: #{error}"
end
print 'F'
end
end end
print 'F'
end end
end end
end end
end end
def create_real_project!(url, force_latest_storage: false)
group_path, project_path = url.split('/')[-2..-1]
group = Group.find_by(path: group_path)
unless group
group = Group.new(
name: group_path.titleize,
path: group_path
)
group.description = FFaker::Lorem.sentence
group.save!
group.add_owner(User.first)
end
project_path.gsub!(".git", "")
params = {
import_url: url,
namespace_id: group.id,
name: project_path.titleize,
description: FFaker::Lorem.sentence,
visibility_level: Gitlab::VisibilityLevel.values.sample,
skip_disk_validation: true
}
if force_latest_storage
params[:storage_version] = Project::LATEST_STORAGE_VERSION
end
project = nil
Sidekiq::Worker.skipping_transaction_check do
project = ::Projects::CreateService.new(User.first, params).execute
# Seed-Fu runs this entire fixture in a transaction, so the `after_commit`
# hook won't run until after the fixture is loaded. That is too late
# since the Sidekiq::Testing block has already exited. Force clearing
# the `after_commit` queue to ensure the job is run now.
project.send(:_run_after_commit_queue)
project.import_state.send(:_run_after_commit_queue)
end
if project.valid? && project.valid_repo?
print '.'
else
puts project.errors.full_messages
print 'F'
end
end
def create_mass_projects!
projects_per_user_count = MASS_PROJECTS_COUNT_PER_USER.values.sum
visibility_per_user = ['private'] * MASS_PROJECTS_COUNT_PER_USER.fetch(:private) +
['internal'] * MASS_PROJECTS_COUNT_PER_USER.fetch(:internal) +
['public'] * MASS_PROJECTS_COUNT_PER_USER.fetch(:public)
visibility_level_per_user = visibility_per_user.map { |visibility| Gitlab::VisibilityLevel.level_value(visibility) }
visibility_per_user = visibility_per_user.join(',')
visibility_level_per_user = visibility_level_per_user.join(',')
Gitlab::Seeder.with_mass_insert(User.count * projects_per_user_count, "Projects and relations") do
ActiveRecord::Base.connection.execute <<~SQL
INSERT INTO projects (name, path, creator_id, namespace_id, visibility_level, created_at, updated_at)
SELECT
'Seed project ' || seq || ' ' || ('{#{visibility_per_user}}'::text[])[seq] AS project_name,
'mass_insert_project_' || ('{#{visibility_per_user}}'::text[])[seq] || '_' || seq AS project_path,
u.id AS user_id,
n.id AS namespace_id,
('{#{visibility_level_per_user}}'::int[])[seq] AS visibility_level,
NOW() AS created_at,
NOW() AS updated_at
FROM users u
CROSS JOIN generate_series(1, #{projects_per_user_count}) AS seq
JOIN namespaces n ON n.owner_id=u.id
SQL
ActiveRecord::Base.connection.execute <<~SQL
INSERT INTO project_features (project_id, merge_requests_access_level, issues_access_level, wiki_access_level,
pages_access_level)
SELECT
id,
#{ProjectFeature::ENABLED} AS merge_requests_access_level,
#{ProjectFeature::ENABLED} AS issues_access_level,
#{ProjectFeature::ENABLED} AS wiki_access_level,
#{ProjectFeature::ENABLED} AS pages_access_level
FROM projects ON CONFLICT (project_id) DO NOTHING;
SQL
ActiveRecord::Base.connection.execute <<~SQL
INSERT INTO routes (source_id, source_type, name, path)
SELECT
p.id,
'Project',
u.name || ' / ' || p.name,
u.username || '/' || p.path
FROM projects p JOIN users u ON u.id=p.creator_id
ON CONFLICT (source_type, source_id) DO NOTHING;
SQL
end
end
end
Gitlab::Seeder.quiet do
projects = Gitlab::Seeder::Projects.new
projects.seed!
end end

View File

@ -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

View File

@ -1,34 +0,0 @@
require './spec/support/sidekiq'
Gitlab::Seeder.quiet do
20.times do |i|
begin
User.create!(
username: FFaker::Internet.user_name,
name: FFaker::Name.name,
email: FFaker::Internet.email,
confirmed_at: DateTime.now,
password: '12345678'
)
print '.'
rescue ActiveRecord::RecordInvalid
print 'F'
end
end
5.times do |i|
begin
User.create!(
username: "user#{i}",
name: "User #{i}",
email: "user#{i}@example.com",
confirmed_at: DateTime.now,
password: '12345678'
)
print '.'
rescue ActiveRecord::RecordInvalid
print 'F'
end
end
end

View File

@ -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
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

View File

@ -1,7 +1,7 @@
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",

View File

@ -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|

View File

@ -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(

View File

@ -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,

View File

@ -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

View File

@ -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'
} }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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",

View File

@ -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`

View File

@ -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`

View File

@ -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:**
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.
| `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
</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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Gitlab
module Graphql
module Connections
# FilterableArrayConnection is useful especially for lazy-loaded values.
# It allows us to call a callback only on the slice of array being
# rendered in the "after loaded" phase. For example we can check
# permissions only on a small subset of items.
class FilterableArrayConnection < GraphQL::Relay::ArrayConnection
def paged_nodes
@filtered_nodes ||= nodes.filter_callback.call(super)
end
end
end
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
module Gitlab
module Graphql
class FilterableArray < Array
attr_reader :filter_callback
def initialize(filter_callback, *args)
super(args)
@filter_callback = filter_callback
end
end
end
end

View File

@ -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] }

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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
yield yield
SeedFu.quiet = false SeedFu.quiet = false
ActiveRecord::Base.logger = old_logger
puts "\nOK".color(:green) puts "\nOK".color(:green)
end end

View File

@ -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

View File

@ -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|

View File

@ -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 ""
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 ""
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 ""
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 ""
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 ""
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 ""

View File

@ -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

View File

@ -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
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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -0,0 +1,144 @@
# frozen_string_literal: true
require 'spec_helper'
describe PrometheusMetricsFinder do
describe '#execute' do
let(:finder) { described_class.new(params) }
let(:params) { {} }
subject { finder.execute }
context 'with params' do
let_it_be(:project) { create(:project) }
let_it_be(:project_metric) { create(:prometheus_metric, project: project) }
let_it_be(:common_metric) { create(:prometheus_metric, :common) }
let_it_be(:unique_metric) do
create(
:prometheus_metric,
:common,
title: 'Unique title',
y_label: 'Unique y_label',
group: :kubernetes,
identifier: 'identifier',
created_at: 5.minutes.ago
)
end
context 'with appropriate indexes' do
before do
allow_any_instance_of(described_class).to receive(:appropriate_index?).and_return(true)
end
context 'with project' do
let(:params) { { project: project } }
it { is_expected.to eq([project_metric]) }
end
context 'with group' do
let(:params) { { group: project_metric.group } }
it { is_expected.to contain_exactly(common_metric, project_metric) }
end
context 'with title' do
let(:params) { { title: project_metric.title } }
it { is_expected.to contain_exactly(project_metric, common_metric) }
end
context 'with y_label' do
let(:params) { { y_label: project_metric.y_label } }
it { is_expected.to contain_exactly(project_metric, common_metric) }
end
context 'with common' do
let(:params) { { common: true } }
it { is_expected.to contain_exactly(common_metric, unique_metric) }
end
context 'with ordered' do
let(:params) { { ordered: true } }
it { is_expected.to eq([unique_metric, project_metric, common_metric]) }
end
context 'with indentifier' do
let(:params) { { identifier: unique_metric.identifier } }
it 'raises an error' do
expect { subject }.to raise_error(
ArgumentError,
':identifier must be scoped to a :project or :common'
)
end
context 'with common' do
let(:params) { { identifier: unique_metric.identifier, common: true } }
it { is_expected.to contain_exactly(unique_metric) }
end
context 'with id' do
let(:params) { { id: 14, identifier: 'string' } }
it 'raises an error' do
expect { subject }.to raise_error(
ArgumentError,
'Only one of :identifier, :id is permitted'
)
end
end
end
context 'with id' do
let(:params) { { id: common_metric.id } }
it { is_expected.to contain_exactly(common_metric) }
end
context 'with multiple params' do
let(:params) do
{
group: project_metric.group,
title: project_metric.title,
y_label: project_metric.y_label,
common: true,
ordered: true
}
end
it { is_expected.to contain_exactly(common_metric) }
end
end
context 'without an appropriate index' do
let(:params) do
{
title: project_metric.title,
ordered: true
}
end
it 'raises an error' do
expect { subject }.to raise_error(
ArgumentError,
'An index should exist for params: [:title]'
)
end
end
end
context 'without params' do
it 'raises an error' do
expect { subject }.to raise_error(
ArgumentError,
'Please provide one or more of: [:project, :group, :title, :y_label, :identifier, :id, :common, :ordered]'
)
end
end
end
end

View File

@ -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,

View File

@ -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 = [
'/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,

View File

@ -1,10 +1,11 @@
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', () => {
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', () => {
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,
});
});
});
}); });

View File

@ -1,10 +1,14 @@
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', () => {
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', () => {
}, },
}); });
wrapper.vm.handleMultipleDelete(); 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', () => { describe('pagination', () => {
const repo = { const repo = {
repoPropsData, repoPropsData,
@ -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',
});
});
});
});
}); });

View File

@ -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', () => {

View File

@ -1,5 +1,3 @@
/* 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() {
}); });
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() {
}); });
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');

View File

@ -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(() => {

View File

@ -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);

View File

@ -1,6 +1,5 @@
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', () => {
); );
}); });
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',

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Graphql::Connections::FilterableArrayConnection do
let(:callback) { proc { |nodes| nodes } }
let(:all_nodes) { Gitlab::Graphql::FilterableArray.new(callback, 1, 2, 3, 4, 5) }
let(:arguments) { {} }
subject(:connection) do
described_class.new(all_nodes, arguments, max_page_size: 3)
end
describe '#paged_nodes' do
let(:paged_nodes) { subject.paged_nodes }
it_behaves_like "connection with paged nodes"
context 'when callback filters some nodes' do
let(:callback) { proc { |nodes| nodes[1..-1] } }
it 'does not return filtered elements' do
expect(subject.paged_nodes).to contain_exactly(all_nodes[1], all_nodes[2])
end
end
end
end

View File

@ -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

View File

@ -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

View File

@ -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
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
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

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
RSpec.shared_examples 'connection with paged nodes' do
it 'returns the collection limited to max page size' do
expect(paged_nodes.size).to eq(3)
end
it 'is a loaded memoized array' do
expect(paged_nodes).to be_an(Array)
expect(paged_nodes.object_id).to eq(paged_nodes.object_id)
end
context 'when `first` is passed' do
let(:arguments) { { first: 2 } }
it 'returns only the first elements' do
expect(paged_nodes).to contain_exactly(all_nodes.first, all_nodes.second)
end
end
context 'when `last` is passed' do
let(:arguments) { { last: 2 } }
it 'returns only the last elements' do
expect(paged_nodes).to contain_exactly(all_nodes[3], all_nodes[4])
end
end
end