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