Commit c27acb1d authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 18084543
......@@ -350,7 +350,7 @@ end
group :development, :test do
gem 'bullet', '~> 6.0.2', require: !!ENV['ENABLE_BULLET']
gem 'pry-byebug', '~> 3.5.1', platform: :mri
gem 'pry-rails', '~> 0.3.4'
gem 'pry-rails', '~> 0.3.9'
gem 'awesome_print', require: false
......
......@@ -778,7 +778,7 @@ GEM
pry-byebug (3.5.1)
byebug (~> 9.1)
pry (~> 0.10)
pry-rails (0.3.6)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.3)
pyu-ruby-sasl (0.0.3.3)
......@@ -1323,7 +1323,7 @@ DEPENDENCIES
premailer-rails (~> 1.10.3)
prometheus-client-mmap (~> 0.10.0)
pry-byebug (~> 3.5.1)
pry-rails (~> 0.3.4)
pry-rails (~> 0.3.9)
rack (~> 2.0.7)
rack-attack (~> 6.2.0)
rack-cors (~> 1.0.0)
......
<script>
import { GlFormInputGroup, GlButton, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlFormInputGroup,
GlButton,
GlIcon,
},
props: {
url: {
type: String,
required: true,
},
},
data() {
return {
optionValues: [
// eslint-disable-next-line no-useless-escape
{ name: __('Embed'), value: `<script src='${this.url}.js'><\/script>` },
{ name: __('Share'), value: this.url },
],
};
},
};
</script>
<template>
<gl-form-input-group
id="embeddable-text"
:predefined-options="optionValues"
readonly
select-on-click
>
<template #append>
<gl-button new-style data-clipboard-target="#embeddable-text">
<gl-icon name="copy-to-clipboard" :title="__('Copy')" />
</gl-button>
</template>
</gl-form-input-group>
</template>
......@@ -19,10 +19,10 @@ import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import Icon from '~/vue_shared/components/icon.vue';
import { getTimeRange } from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import GraphGroup from './graph_group.vue';
......@@ -31,11 +31,8 @@ import GroupEmptyState from './group_empty_state.vue';
import DashboardsDropdown from './dashboards_dropdown.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getAddMetricTrackingOptions } from '../utils';
import { datePickerTimeWindows, metricStates } from '../constants';
const defaultTimeRange = getTimeRange();
import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils';
import { defaultTimeRange, timeRanges, metricStates } from '../constants';
export default {
components: {
......@@ -197,10 +194,9 @@ export default {
return {
state: 'gettingStarted',
formIsValid: null,
startDate: getParameterValues('start')[0] || defaultTimeRange.start,
endDate: getParameterValues('end')[0] || defaultTimeRange.end,
selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
hasValidDates: true,
datePickerTimeWindows,
timeRanges,
isRearrangingPanels: false,
};
},
......@@ -260,9 +256,11 @@ export default {
if (!this.hasMetrics) {
this.setGettingStartedEmptyState();
} else {
const { start, end } = convertToFixedRange(this.selectedTimeRange);
this.fetchData({
start: this.startDate,
end: this.endDate,
start,
end,
});
}
},
......@@ -287,8 +285,8 @@ export default {
});
},
onDateTimePickerApply(params) {
redirectTo(mergeUrlParams(params, window.location.href));
onDateTimePickerInput(timeRange) {
redirectTo(timeRangeToUrl(timeRange));
},
onDateTimePickerInvalid() {
createFlash(
......@@ -296,8 +294,8 @@ export default {
'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.',
),
);
this.startDate = defaultTimeRange.start;
this.endDate = defaultTimeRange.end;
// As a fallback, switch to default time range instead
this.selectedTimeRange = defaultTimeRange;
},
generateLink(group, title, yLabel) {
......@@ -447,10 +445,9 @@ export default {
>
<date-time-picker
ref="dateTimePicker"
:start="startDate"
:end="endDate"
:time-windows="datePickerTimeWindows"
@apply="onDateTimePickerApply"
:value="selectedTimeRange"
:options="timeRanges"
@input="onDateTimePickerInput"
@invalid="onDateTimePickerInvalid"
/>
</gl-form-group>
......
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
import { sidebarAnimationDuration } from '../constants';
import { getTimeRange } from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { timeRangeFromUrl, removeTimeRangeParams } from '../utils';
import { sidebarAnimationDuration, defaultTimeRange } from '../constants';
let sidebarMutationObserver;
......@@ -18,10 +18,8 @@ export default {
},
},
data() {
const defaultRange = getTimeRange();
const start = getParameterValues('start', this.dashboardUrl)[0] || defaultRange.start;
const end = getParameterValues('end', this.dashboardUrl)[0] || defaultRange.end;
const timeRange = timeRangeFromUrl(this.dashboardUrl) || defaultTimeRange;
const { start, end } = convertToFixedRange(timeRange);
const params = {
start,
end,
......@@ -81,7 +79,7 @@ export default {
},
setInitialState() {
this.setEndpoints({
dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl),
dashboardEndpoint: removeTimeRangeParams(this.dashboardUrl),
});
this.setShowErrorBanner(false);
},
......
......@@ -83,34 +83,36 @@ export const dateFormats = {
default: 'dd mmm yyyy, h:MMTT',
};
export const datePickerTimeWindows = {
thirtyMinutes: {
export const timeRanges = [
{
label: __('30 minutes'),
seconds: 60 * 30,
duration: { seconds: 60 * 30 },
},
threeHours: {
{
label: __('3 hours'),
seconds: 60 * 60 * 3,
duration: { seconds: 60 * 60 * 3 },
},
eightHours: {
{
label: __('8 hours'),
seconds: 60 * 60 * 8,
duration: { seconds: 60 * 60 * 8 },
default: true,
},
oneDay: {
{
label: __('1 day'),
seconds: 60 * 60 * 24 * 1,
duration: { seconds: 60 * 60 * 24 * 1 },
},
threeDays: {
{
label: __('3 days'),
seconds: 60 * 60 * 24 * 3,
duration: { seconds: 60 * 60 * 24 * 3 },
},
oneWeek: {
{
label: __('1 week'),
seconds: 60 * 60 * 24 * 7 * 1,
duration: { seconds: 60 * 60 * 24 * 7 * 1 },
},
twoWeeks: {
{
label: __('2 weeks'),
seconds: 60 * 60 * 24 * 7 * 2,
duration: { seconds: 60 * 60 * 24 * 7 * 2 },
},
};
];
export const defaultTimeRange = timeRanges.find(tr => tr.default);
......@@ -2,6 +2,7 @@
import GetSnippetQuery from '../queries/snippet.query.graphql';
import SnippetHeader from './snippet_header.vue';
import SnippetTitle from './snippet_title.vue';
import SnippetBlob from './snippet_blob_view.vue';
import { GlLoadingIcon } from '@gitlab/ui';
export default {
......@@ -9,6 +10,7 @@ export default {
SnippetHeader,
SnippetTitle,
GlLoadingIcon,
SnippetBlob,
},
apollo: {
snippet: {
......@@ -50,6 +52,7 @@ export default {
<template v-else>
<snippet-header :snippet="snippet" />
<snippet-title :snippet="snippet" />
<snippet-blob :snippet="snippet" />
</template>
</div>
</template>
<script>
import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
import { SNIPPET_VISIBILITY_PUBLIC } from '../constants';
export default {
components: {
BlobEmbeddable,
},
props: {
snippet: {
type: Object,
required: true,
},
},
computed: {
embeddable() {
return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC;
},
},
};
</script>
<template>
<div>
<blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" />
</div>
</template>
export const SNIPPET_VISIBILITY_PRIVATE = 'private';
export const SNIPPET_VISIBILITY_INTERNAL = 'internal';
export const SNIPPET_VISIBILITY_PUBLIC = 'public';
......@@ -12,8 +12,7 @@
* css-class="btn-transparent"
* />
*/
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import Icon from '../components/icon.vue';
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
export default {
name: 'ClipboardButton',
......@@ -22,7 +21,7 @@ export default {
},
components: {
GlButton,
Icon,
GlIcon,
},
props: {
text: {
......@@ -72,6 +71,6 @@ export default {
:title="title"
:data-clipboard-text="clipboardText"
>
<icon name="duplicate" />
<gl-icon name="copy-to-clipboard" />
</gl-button>
</template>
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range';
import Icon from '~/vue_shared/components/icon.vue';
import DateTimePickerInput from './date_time_picker_input.vue';
import {
defaultTimeWindows,
defaultTimeRanges,
defaultTimeRange,
isValidDate,
getTimeRange,
getTimeWindowKey,
stringToISODate,
ISODateToString,
truncateZerosInDateTime,
......@@ -15,7 +17,7 @@ import {
} from './date_time_picker_lib';
const events = {
apply: 'apply',
input: 'input',
invalid: 'invalid',
};
......@@ -29,24 +31,22 @@ export default {
GlDropdownItem,
},
props: {
start: {
type: String,
required: true,
},
end: {
type: String,
required: true,
},
timeWindows: {
value: {
type: Object,
required: false,
default: () => defaultTimeWindows,
default: () => defaultTimeRange,
},
options: {
type: Array,
required: false,
default: () => defaultTimeRanges,
},
},
data() {
return {
startDate: this.start,
endDate: this.end,
timeRange: this.value,
startDate: '',
endDate: '',
};
},
computed: {
......@@ -67,6 +67,7 @@ export default {
set(val) {
// Attempt to set a formatted date if possible
this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
this.timeRange = null;
},
},
endInput: {
......@@ -76,23 +77,48 @@ export default {
set(val) {
// Attempt to set a formatted date if possible
this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
this.timeRange = null;
},
},
timeWindowText() {
const timeWindow = getTimeWindowKey({ start: this.start, end: this.end }, this.timeWindows);
if (timeWindow) {
return this.timeWindows[timeWindow].label;
} else if (isValidDate(this.start) && isValidDate(this.end)) {
return sprintf(__('%{start} to %{end}'), {
start: this.formatDate(this.start),
end: this.formatDate(this.end),
});
try {
const timeRange = findTimeRange(this.value, this.options);
if (timeRange) {
return timeRange.label;
}
const { start, end } = convertToFixedRange(this.value);
if (isValidDate(start) && isValidDate(end)) {
return sprintf(__('%{start} to %{end}'), {
start: this.formatDate(start),
end: this.formatDate(end),
});
}
} catch {
return __('Invalid date range');
}
return '';
},
},
watch: {
value(newValue) {
const { start, end } = convertToFixedRange(newValue);
this.timeRange = this.value;
this.startDate = start;
this.endDate = end;
},
},
mounted() {
try {
const { start, end } = convertToFixedRange(this.timeRange);
this.startDate = start;
this.endDate = end;
} catch {
// when dates cannot be parsed, emit error.
this.$emit(events.invalid);
}
// Validate on mounted, and trigger an update if needed
if (!this.isValid) {
this.$emit(events.invalid);
......@@ -102,21 +128,22 @@ export default {
formatDate(date) {
return truncateZerosInDateTime(ISODateToString(date));
},
setTimeWindow(key) {
const { start, end } = getTimeRange(key, this.timeWindows);
this.startDate = start;
this.endDate = end;
this.apply();
},
closeDropdown() {
this.$refs.dropdown.hide();
},
apply() {
this.$emit(events.apply, {
isOptionActive(option) {
return isEqualTimeRanges(option, this.timeRange);
},
setQuickRange(option) {
this.timeRange = option;
this.$emit(events.input, this.timeRange);
},
setFixedRange() {
this.timeRange = convertToFixedRange({
start: this.startDate,
end: this.endDate,
});
this.$emit(events.input, this.timeRange);
},
},
};
......@@ -146,7 +173,7 @@ export default {
</div>
<gl-form-group>
<gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
<gl-button variant="success" :disabled="!isValid" @click="apply()">
<gl-button variant="success" :disabled="!isValid" @click="setFixedRange()">
{{ __('Apply') }}
</gl-button>
</gl-form-group>
......@@ -155,19 +182,20 @@ export default {
<template #label>
<span class="gl-pl-5">{{ __('Quick range') }}</span>
</template>
<gl-dropdown-item
v-for="(timeWindow, key) in timeWindows"
:key="key"
:active="timeWindow.label === timeWindowText"
v-for="(option, index) in options"
:key="index"
:active="isOptionActive(option)"
active-class="active"
@click="setTimeWindow(key)"
@click="setQuickRange(option)"
>
<icon
name="mobile-issue-close"
class="align-bottom"
:class="{ invisible: timeWindow.label !== timeWindowText }"
:class="{ invisible: !isOptionActive(option) }"
/>
{{ timeWindow.label }}
{{ option.label }}
</gl-dropdown-item>
</gl-form-group>
</div>
......
import dateformat from 'dateformat';
import { __ } from '~/locale';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
/**
* Valid strings for this regex are
......@@ -9,37 +8,30 @@ import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
const dateTimePickerRegex = /^(\d{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])(?: (0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]))?$/;
/**
* A key-value pair of "time windows".
*
* A time window is a representation of period of time that starts
* some time in past until now. Keys are only used for easy reference.
*
* It is represented as user friendly `label` and number of `seconds`
* to be substracted from now.
* Default time ranges for the date picker.
* @see app/assets/javascripts/lib/utils/datetime_range.js
*/
export const defaultTimeWindows = {
thirtyMinutes: {
export const defaultTimeRanges = [
{
duration: { seconds: 60 * 30 },
label: __('30 minutes'),
seconds: 60 * 30,
},
threeHours: {
{
duration: { seconds: 60 * 60 * 3 },
label: __('3 hours'),
seconds: 60 * 60 * 3,
},
eightHours: {
{
duration: { seconds: 60 * 60 * 8 },
label: __('8 hours'),
seconds: 60 * 60 * 8,
default: true,
},
oneDay: {
{
duration: { seconds: 60 * 60 * 24 * 1 },
label: __('1 day'),
seconds: 60 * 60 * 24 * 1,
},
threeDays: {
label: __('3 days'),
seconds: 60 * 60 * 24 * 3,
},
};
];
export const defaultTimeRange = defaultTimeRanges.find(tr => tr.default);
export const dateFormats = {
ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'",
......@@ -67,46 +59,6 @@ export const isValidDate = dateString => {
}
};
/**
* For a given time window key (e.g. `threeHours`) and key-value pair
* object of time windows.
*
* Returns a date time range with start and end.
*
* @param {String} timeWindowKey - A key in the object of time windows.
* @param {Object} timeWindows - A key-value pair of time windows,
* with a second duration and a label.
* @returns An object with time range, start and end dates, in ISO format.
*/
export const getTimeRange = (timeWindowKey, timeWindows = defaultTimeWindows) => {
let difference;
if (timeWindows[timeWindowKey]) {
difference = timeWindows[timeWindowKey].seconds;
} else {
const [defaultEntry] = Object.entries(timeWindows).filter(
([, timeWindow]) => timeWindow.default,
);
// find default time window
difference = defaultEntry[1].seconds;
}
const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds
const start = end - difference;
return {
start: new Date(secondsToMilliseconds(start)).toISOString(),
end: new Date(secondsToMilliseconds(end)).toISOString(),
};
};
export const getTimeWindowKey = ({ start, end }, timeWindows = defaultTimeWindows) =>
Object.entries(timeWindows).reduce((acc, [timeWindowKey, timeWindow]) => {
if (new Date(end) - new Date(start) === secondsToMilliseconds(timeWindow.seconds)) {
return timeWindowKey;
}
return acc;
}, null);
/**
* Convert the input in Time picker component to ISO date.
*
......
---
title: Allow for relative time ranges in metrics dashboard URLs
merge_request: 23765
author:
type: added
---
title: Remove unused Code Hotspots database tables
merge_request: 23590
author:
type: other
---
title: Fix visibility levels of subgroups to be not higher than their parents' level
merge_request: 22889
author:
type: other
---
title: Updated icon for copy-to-clipboard button
merge_request: 24146
author:
type: other
# frozen_string_literal: true
class RemoveAnalyticsRepositoryTableFksOnProjects < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
with_lock_retries do
# Requires ExclusiveLock on all tables. analytics_* tables are empty
remove_foreign_key :analytics_repository_files, :projects
remove_foreign_key :analytics_repository_file_edits, :projects
remove_foreign_key :analytics_repository_file_commits, :projects
end
end
def down
with_lock_retries do
# rubocop:disable Migration/AddConcurrentForeignKey
add_foreign_key :analytics_repository_files, :projects, on_delete: :cascade
add_foreign_key :analytics_repository_file_edits, :projects, on_delete: :cascade
add_foreign_key :analytics_repository_file_commits, :projects, on_delete: :cascade
# rubocop:enable Migration/AddConcurrentForeignKey
end
end
end
# frozen_string_literal: true
class RemoveAnalyticsRepositoryFilesFkOnOtherAnalyticsTables < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
with_lock_retries do
# Requires ExclusiveLock on all tables. analytics_* tables are empty
remove_foreign_key :analytics_repository_file_edits, :analytics_repository_files
remove_foreign_key :analytics_repository_file_commits, :analytics_repository_files
end
end
def down
with_lock_retries do
# rubocop:disable Migration/AddConcurrentForeignKey
add_foreign_key :analytics_repository_file_edits, :analytics_repository_files, on_delete: :cascade
add_foreign_key :analytics_repository_file_commits, :analytics_repository_files, on_delete: :cascade
# rubocop:enable Migration/AddConcurrentForeignKey
end
end
end
# frozen_string_literal: true
class DropAnalyticsRepositoryFilesTable < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
# Requires ExclusiveLock on the table. Not in use, no records, no FKs.
drop_table :analytics_repository_files
end
def down
create_table :analytics_repository_files do |t|
t.bigint :project_id, null: false
t.string :file_path, limit: 4096, null: false
end
add_index :analytics_repository_files, [:project_id, :file_path], unique: true
end
end
# frozen_string_literal: true
class DropAnalyticsRepositoryFileCommitsTable < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
# Requires ExclusiveLock on the table. Not in use, no records, no FKs.
drop_table :analytics_repository_file_commits
end
def down
create_table :analytics_repository_file_commits do |t|
t.bigint :analytics_repository_file_id, null: false
t.index :analytics_repository_file_id, name: 'index_analytics_repository_file_commits_file_id'
t.bigint :project_id, null: false
t.date :committed_date, null: false
t.integer :commit_count, limit: 2, null: false
end
add_index :analytics_repository_file_commits,
[:project_id, :committed_date, :analytics_repository_file_id],
name: 'index_file_commits_on_committed_date_file_id_and_project_id',
unique: true
end
end
# frozen_string_literal: true
class DropAnalyticsRepositoryFileEditsTable < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
# Requires ExclusiveLock on the table. Not in use, no records, no FKs.
drop_table :analytics_repository_file_edits
end
def down
create_table :analytics_repository_file_edits do |t|
t.bigint :project_id, null: false
t.index :project_id
t.bigint :analytics_repository_file_id, null: false
t.date :committed_date, null: false
t.integer :num_edits, null: false, default: 0
end
add_index :analytics_repository_file_edits,
[:analytics_repository_file_id, :committed_date, :project_id],
name: 'index_file_edits_on_committed_date_file_id_and_project_id',
unique: true
end
end
# frozen_string_literal: true
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class ScheduleUpdateExistingSubgroupToMatchVisibilityLevelOfParent < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
MIGRATION = 'UpdateExistingSubgroupToMatchVisibilityLevelOfParent'
DELAY_INTERVAL = 5.minutes.to_i
BATCH_SIZE = 1000
VISIBILITY_LEVELS = {
internal: 10,
private: 0
}
disable_ddl_transaction!
def up
offset = update_groups(VISIBILITY_LEVELS[:internal])
update_groups(VISIBILITY_LEVELS[:private], offset: offset)
end
def down
# no-op
end
private
def update_groups(level, offset: 0)
groups = exec_query <<~SQL
SELECT id
FROM namespaces
WHERE visibility_level = #{level}
AND type = 'Group'
AND EXISTS (SELECT 1
FROM namespaces AS children
WHERE children.parent_id = namespaces.id)
SQL
ids = groups.rows.flatten
iterator = 1
ids.in_groups_of(BATCH_SIZE, false) do |batch_of_ids|
delay = DELAY_INTERVAL * (iterator + offset)
BackgroundMigrationWorker.perform_in(delay, MIGRATION, [batch_of_ids, level])
iterator += 1
end
say("Background jobs for visibility level #{level} scheduled in #{iterator} iterations")
offset + iterator
end
end
......@@ -94,30 +94,6 @@ ActiveRecord::Schema.define(version: 2020_01_27_090233) do
t.index ["project_id"], name: "analytics_repository_languages_on_project_id"
end
create_table "analytics_repository_file_commits", force: :cascade do |t|
t.bigint "analytics_repository_file_id", null: false
t.bigint "project_id", null: false
t.date "committed_date", null: false
t.integer "commit_count", limit: 2, null: false
t.index ["analytics_repository_file_id"], name: "index_analytics_repository_file_commits_file_id"
t.index ["project_id", "committed_date", "analytics_repository_file_id"], name: "index_file_commits_on_committed_date_file_id_and_project_id", unique: true
end
create_table "analytics_repository_file_edits", force: :cascade do |t|
t.bigint "project_id", null: false
t.bigint "analytics_repository_file_id", null: false
t.date "committed_date", null: false
t.integer "num_edits", default: 0, null: false
t.index ["analytics_repository_file_id", "committed_date", "project_id"], name: "index_file_edits_on_committed_date_file_id_and_project_id", unique: true
t.index ["project_id"], name: "index_analytics_repository_file_edits_on_project_id"
end
create_table "analytics_repository_files", force: :cascade do |t|
t.bigint "project_id", null: false
t.string "file_path", limit: 4096, null: false
t.index ["project_id", "file_path"], name: "index_analytics_repository_files_on_project_id_and_file_path", unique: true
end
create_table "appearances", id: :serial, force: :cascade do |t|
t.string "title", null: false
t.text "description", null: false
......@@ -4476,11 +4452,6 @@ ActiveRecord::Schema.define(version: 2020_01_27_090233) do
add_foreign_key "analytics_cycle_analytics_project_stages", "projects", on_delete: :cascade
add_foreign_key "analytics_language_trend_repository_languages", "programming_languages", on_delete: :cascade
add_foreign_key "analytics_language_trend_repository_languages", "projects", on_delete: :cascade
add_foreign_key "analytics_repository_file_commits", "analytics_repository_files", on_delete: :cascade
add_foreign_key "analytics_repository_file_commits", "projects", on_delete: :cascade
add_foreign_key "analytics_repository_file_edits", "analytics_repository_files", on_delete: :cascade
add_foreign_key "analytics_repository_file_edits", "projects", on_delete: :cascade
add_foreign_key "analytics_repository_files", "projects", on_delete: :cascade
add_foreign_key "application_settings", "namespaces", column: "custom_project_templates_group_id", on_delete: :nullify
add_foreign_key "application_settings", "namespaces", column: "instance_administrators_group_id", name: "fk_e8a145f3a7", on_delete: :nullify
add_foreign_key "application_settings", "projects", column: "file_template_project_id", name: "fk_ec757bd087", on_delete: :nullify
......
......@@ -1062,17 +1062,36 @@ a helpful link back to how the feature was developed.
> [Introduced](<link-to-issue>) in [GitLab Starter](https://about.gitlab.com/pricing/) 11.3.
```
### Removing version text
Over time, version text will reference a progressively older version of GitLab. In cases where version text
refers to versions of GitLab four or more major versions back, consider removing the text.
### Importance of referencing GitLab versions and tiers
Mentioning GitLab versions and tiers is important to all users and contributors
to quickly have access to the issue or merge request that
introduced the change for reference. Also, they can easily understand what
features they have in their GitLab instance and version, given that the note has
some key information.
`[Introduced](link-to-issue) in [GitLab Premium](https://about.gitlab.com/pricing) 12.7`
links to the issue that introduced the feature, says which GitLab tier it
belongs to, says the GitLab version that it became available in, and links to
the pricing page in case the user wants to upgrade to a paid tier
to use that feature.
For example, if I'm a regular user and I'm looking at the docs for a feature I haven't used before,
I can immediately see if that feature is available to me or not. Alternatively,
if I have been using a certain feature for a long time and it changed in some way,
it's important
to me to spot when it changed and what's new in that feature.
This is even more important as we don't have a perfect process for shipping docs.
Unfortunately, we still see features without docs and docs without
features. So, for now, we cannot rely 100% on the docs site versions.
Over time, version text will reference a progressively older version of GitLab.
In cases where version text refers to versions of GitLab four or more major
versions back, you can consider removing the text if it's irrelevant or confusing.
For example, if the current major version is 12.x, version text referencing versions of GitLab 8.x
and older are candidates for removal.
NOTE: **Note:**
This guidance applies to any text that mentions a GitLab version, not just "Introduced in... " text.
Other text includes deprecation notices and version-specific how-to information.
and older are candidates for removal if necessary for clearer or cleaner docs.
## Product badges
......@@ -1103,6 +1122,8 @@ The tier should be ideally added to headers, so that the full badge will be disp
However, it can be also mentioned from paragraphs, list items, and table cells. For these cases,
the tier mention will be represented by an orange question mark that will show the tiers on hover.
Use the lowest tier at the page level, even if higher-level tiers exist on the page. For example, you might have a page that is marked as Starter but a section badged as Premium.
For example:
- `**(STARTER)**` renders as **(STARTER)**
......
......@@ -174,14 +174,14 @@ sequenceDiagram
c ->>+w: POST /some/url/upload
w->>+s: save the incoming file on a temporary location
s-->>-w:
s-->>-w: request result
w->>+r: POST /some/url/upload
Note over w,r: file was replaced with its location<br>and other metadata
opt requires async processing
r->>+redis: schedule a job
redis-->>-r:
redis-->>-r: job is scheduled
end
r-->>-c: request result
......@@ -208,9 +208,11 @@ This is the more advanced acceleration technique we have in place.
Workhorse asks rails for temporary pre-signed object storage URLs and directly uploads to object storage.
In this setup an extra rails route needs to be implemented in order to handle authorization,
you can see an example of this in [`Projects::LfsStorageController`](https://gitlab.com/gitlab-org/gitlab/blob/cc723071ad337573e0360a879cbf99bc4fb7adb9/app/controllers/projects/lfs_storage_controller.rb)
and [its routes](https://gitlab.com/gitlab-org/gitlab/blob/cc723071ad337573e0360a879cbf99bc4fb7adb9/config/routes/git_http.rb#L31-32).
In this setup, an extra Rails route must be implemented in order to handle authorization. Examples of this can be found in:
- [`Projects::LfsStorageController`](https://gitlab.com/gitlab-org/gitlab/blob/cc723071ad337573e0360a879cbf99bc4fb7adb9/app/controllers/projects/lfs_storage_controller.rb)
and [its routes](https://gitlab.com/gitlab-org/gitlab/blob/cc723071ad337573e0360a879cbf99bc4fb7adb9/config/routes/git_http.rb#L31-32).
- [API endpoints for uploading packages](packages.md#file-uploads).
**note:** this will fallback to _disk buffered upload_ when `direct_upload` is disabled inside the [object storage setting](../administration/uploads.md#object-storage-settings).
The answer to the `/authorize` call will only contain a file system path.
......@@ -231,17 +233,17 @@ sequenceDiagram
w->>+os: PUT file
Note over w,os: file is stored on a temporary location. Rails select the destination
os-->>-w:
os-->>-w: request result
w->>+r: POST /some/url/upload
Note over w,r: file was replaced with its location<br>and other metadata
r->>+os: move object to final destination
os-->>-r:
os-->>-r: request result
opt requires async processing
r->>+redis: schedule a job
redis-->>-r:
redis-->>-r: job is scheduled
end
r-->>-c: request result
......
......@@ -9,4 +9,7 @@ You can import your existing repositories by providing the Git URL:
1. Click **Create project** to begin the import process
1. Once complete, you will be redirected to your newly created project
NOTE: **Note:**
If your password has special characters, you will need to enter them URL encoded, please see the [GitLab issue](https://gitlab.com/gitlab-org/gitlab/issues/29952) for more information.
![Import project by repo URL](img/import_projects_from_repo_url.png)
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
# This background migration updates children of group to match visibility of a parent
class UpdateExistingSubgroupToMatchVisibilityLevelOfParent
def perform(parents_groups_ids, level)
groups_ids = Gitlab::ObjectHierarchy.new(Group.where(id: parents_groups_ids))
.base_and_descendants
.where("visibility_level > ?", level)
.select(:id)
return if groups_ids.empty?
Group
.where(id: groups_ids)
.update_all(visibility_level: level)
end
end
end
end
......@@ -147,11 +147,11 @@ module Gitlab
end
def current_lock_timeout_in_ms
timing_configuration[current_iteration - 1][0].in_milliseconds
Integer(timing_configuration[current_iteration - 1][0].in_milliseconds)
end
def current_sleep_time_in_seconds
timing_configuration[current_iteration - 1][1].to_i
timing_configuration[current_iteration - 1][1].to_f
end
end
end
......
......@@ -10431,6 +10431,9 @@ msgstr ""
msgid "Invalid date format. Please use UTC format as YYYY-MM-DD"
msgstr ""
msgid "Invalid date range"
msgstr ""
msgid "Invalid feature"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
import { GlFormInputGroup } from '@gitlab/ui';
describe('Blob Embeddable', () => {
let wrapper;
const url = 'https://foo.bar';
function createComponent() {
wrapper = shallowMount(BlobEmbeddable, {
propsData: {
url,
},
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders gl-form-input-group component', () => {
expect(wrapper.find(GlFormInputGroup).exists()).toBe(true);
});
it('makes up optionValues based on the url prop', () => {
expect(wrapper.vm.optionValues).toEqual([
{ name: 'Embed', value: expect.stringContaining(`${url}.js`) },
{ name: 'Share', value: url },
]);
});
});
......@@ -69,9 +69,8 @@ exports[`Dashboard template matches the default snapshot 1`] = `
label-size="sm"
>
<date-time-picker-stub
end="2020-01-01T18:57:47.000Z"
start="2020-01-01T18:27:47.000Z"
timewindows="[object Object]"
options="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]"
value="[object Object]"
/>
</gl-form-group-stub>
......
......@@ -5,13 +5,7 @@ import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores';
import { propsData } from '../init_utils';
jest.mock('~/lib/utils/url_utility', () => ({
getParameterValues: jest.fn().mockImplementation(param => {
if (param === 'start') return ['2020-01-01T18:27:47.000Z'];
if (param === 'end') return ['2020-01-01T18:57:47.000Z'];
return [];
}),
}));
jest.mock('~/lib/utils/url_utility');
describe('Dashboard template', () => {
let wrapper;
......
import { mount } from '@vue/test-utils';
import createFlash from '~/flash';
import MockAdapter from 'axios-mock-adapter';
import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores';
import { propsData } from '../init_utils';
import axios from '~/lib/utils/axios_utils';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
getParameterValues: jest.fn().mockReturnValue('<script>alert("XSS")</script>'),
}));
describe('dashboard invalid url parameters', () => {
let store;
let wrapper;
let mock;
const createMountedWrapper = (props = {}, options = {}) => {
wrapper = mount(Dashboard, {
propsData: { ...propsData, ...props },
store,
...options,
});
};
beforeEach(() => {
store = createStore();
mock = new MockAdapter(axios);
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
mock.restore();
});
it('shows an error message if invalid url parameters are passed', done => {
createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
wrapper.vm
.$nextTick()
.then(() => {
expect(createFlash).toHaveBeenCalled();
done();
})
.catch(done.fail);
});
});
import { mount } from '@vue/test-utils';
import { GlDropdownItem } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import statusCodes from '~/lib/utils/http_status';
import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores';
import { propsData, setupComponentStore } from '../init_utils';
import { metricsDashboardPayload, mockApiEndpoint } from '../mock_data';
jest.mock('~/lib/utils/url_utility', () => ({
getParameterValues: jest.fn().mockImplementation(param => {
if (param === 'start') return ['2019-10-01T18:27:47.000Z'];
if (param === 'end') return ['2019-10-01T18:57:47.000Z'];
return [];
}),
mergeUrlParams: jest.fn().mockReturnValue('#'),
}));
describe('dashboard time window', () => {
let store;
let wrapper;
let mock;
const createComponentWrapperMounted = (props = {}, options = {}) => {
wrapper = mount(Dashboard, {
propsData: { ...propsData, ...props },
store,
...options,
});
};
beforeEach(() => {
store = createStore();
mock = new MockAdapter(axios);
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
mock.restore();
});
it('shows an active quick range option', done => {
mock.onGet(mockApiEndpoint).reply(statusCodes.OK, metricsDashboardPayload);
createComponentWrapperMounted({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
setupComponentStore(wrapper);
wrapper.vm
.$nextTick()
.then(() => {
const timeWindowDropdownItems = wrapper
.find({ ref: 'dateTimePicker' })
.findAll(GlDropdownItem);
const activeItem = timeWindowDropdownItems.wrappers.filter(itemWrapper =>
itemWrapper.find('.active').exists(),
);
expect(activeItem.length).toBe(1);
done();
})
.catch(done.fail);
});
});
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import createFlash from '~/flash';
import { queryToObject, redirectTo, removeParams, mergeUrlParams } from '~/lib/utils/url_utility';
import axios from '~/lib/utils/axios_utils';
import { mockProjectDir } from '../mock_data';
import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores';
import { propsData } from '../init_utils';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility');
describe('dashboard invalid url parameters', () => {
let store;
let wrapper;
let mock;
const fetchDataMock = jest.fn();
const createMountedWrapper = (props = { hasMetrics: true }, options = {}) => {
wrapper = mount(Dashboard, {
propsData: { ...propsData, ...props },
store,
stubs: ['graph-group', 'panel-type'],
methods: {
fetchData: fetchDataMock,
},
...options,
});
};
const findDateTimePicker = () => wrapper.find({ ref: 'dateTimePicker' });
beforeEach(() => {
store = createStore();
mock = new MockAdapter(axios);
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
mock.restore();
fetchDataMock.mockReset();
queryToObject.mockReset();
});
it('passes default url parameters to the time range picker', () => {
queryToObject.mockReturnValue({});
createMountedWrapper();
return wrapper.vm.$nextTick().then(() => {
expect(findDateTimePicker().props('value')).toMatchObject({
duration: { seconds: 28800 },
});
expect(fetchDataMock).toHaveBeenCalledTimes(1);
expect(fetchDataMock).toHaveBeenCalledWith({
start: expect.any(String),
end: expect.any(String),
});
});
});
it('passes a fixed time range in the URL to the time range picker', () => {
const params = {
start: '2019-01-01T00:00:00.000Z',
end: '2019-01-10T00:00:00.000Z',
};
queryToObject.mockReturnValue(params);
createMountedWrapper();
return wrapper.vm.$nextTick().then(() => {
expect(findDateTimePicker().props('value')).toEqual(params);
expect(fetchDataMock).toHaveBeenCalledTimes(1);
expect(fetchDataMock).toHaveBeenCalledWith(params);
});
});
it('passes a rolling time range in the URL to the time range picker', () => {
queryToObject.mockReturnValue({
duration_seconds: '120',
});
createMountedWrapper();
return wrapper.vm.$nextTick().then(() => {
expect(findDateTimePicker().props('value')).toMatchObject({
duration: { seconds: 60 * 2 },
});
expect(fetchDataMock).toHaveBeenCalledTimes(1);
expect(fetchDataMock).toHaveBeenCalledWith({
start: expect.any(String),
end: expect.any(String),
});
});
});
it('shows an error message and loads a default time range if invalid url parameters are passed', () => {
queryToObject.mockReturnValue({
start: '<script>alert("XSS")</script>',
end: '<script>alert("XSS")</script>',
});
createMountedWrapper();
return wrapper.vm.$nextTick().then(() => {
expect(createFlash).toHaveBeenCalled();
expect(findDateTimePicker().props('value')).toMatchObject({
duration: { seconds: 28800 },
});
expect(fetchDataMock).toHaveBeenCalledTimes(1);
expect(fetchDataMock).toHaveBeenCalledWith({
start: expect.any(String),
end: expect.any(String),
});
});
});
it('redirects to different time range', () => {
const toUrl = `${mockProjectDir}/-/environments/1/metrics`;
removeParams.mockReturnValueOnce(toUrl);
createMountedWrapper();
return wrapper.vm.$nextTick().then(() => {
findDateTimePicker().vm.$emit('input', {
duration: { seconds: 120 },
});
// redirect to plus + new parameters
expect(mergeUrlParams).toHaveBeenCalledWith({ duration_seconds: '120' }, toUrl);
expect(redirectTo).toHaveBeenCalledTimes(1);
});
});
});
......@@ -90,11 +90,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
type="button"
>
<svg
aria-hidden="true"
class="s16 ic-duplicate"
class="gl-icon s16"
>
<use
xlink:href="#duplicate"
href="#copy-to-clipboard"
/>
</svg>
</button>
......@@ -128,11 +127,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
type="button"
>
<svg
aria-hidden="true"
class="s16 ic-duplicate"
class="gl-icon s16"
>
<use
xlink:href="#duplicate"
href="#copy-to-clipboard"
/>
</svg>
</button>
......@@ -158,11 +156,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
type="button"
>
<svg
aria-hidden="true"
class="s16 ic-duplicate"
class="gl-icon s16"
>
<use
xlink:href="#duplicate"
href="#copy-to-clipboard"
/>
</svg>
</button>
......
import SnippetApp from '~/snippets/components/app.vue';
import SnippetHeader from '~/snippets/components/snippet_header.vue';
import SnippetTitle from '~/snippets/components/snippet_title.vue';
import SnippetBlob from '~/snippets/components/snippet_blob_view.vue';
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
......@@ -35,8 +37,10 @@ describe('Snippet view app', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('renders SnippetHeader component after the query is finished', () => {
it('renders all components after the query is finished', () => {
createComponent();
expect(wrapper.find(SnippetHeader).exists()).toBe(true);
expect(wrapper.find(SnippetTitle).exists()).toBe(true);
expect(wrapper.find(SnippetBlob).exists()).toBe(true);
});
});
import { shallowMount } from '@vue/test-utils';
import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue';
import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
import {
SNIPPET_VISIBILITY_PRIVATE,
SNIPPET_VISIBILITY_INTERNAL,
SNIPPET_VISIBILITY_PUBLIC,
} from '~/snippets/constants';
describe('Blob Embeddable', () => {
let wrapper;
const snippet = {
id: 'gid://foo.bar/snippet',
webUrl: 'https://foo.bar',
visibilityLevel: SNIPPET_VISIBILITY_PUBLIC,
};
function createComponent(props = {}) {
wrapper = shallowMount(SnippetBlobView, {
propsData: {
snippet: {
...snippet,
...props,
},
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('renders blob-embeddable component', () => {
createComponent();
expect(wrapper.find(BlobEmbeddable).exists()).toBe(true);
});
it('does not render blob-embeddable for internal snippet', () => {
createComponent({
visibilityLevel: SNIPPET_VISIBILITY_INTERNAL,
});
expect(wrapper.find(BlobEmbeddable).exists()).toBe(false);
createComponent({
visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
});
expect(wrapper.find(BlobEmbeddable).exists()).toBe(false);
createComponent({
visibilityLevel: 'foo',
});
expect(wrapper.find(BlobEmbeddable).exists()).toBe(false);
});
});
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import { GlButton, GlIcon } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
describe('clipboard button', () => {
let wrapper;
......@@ -29,7 +28,7 @@ describe('clipboard button', () => {
it('renders a button for clipboard', () => {
expect(wrapper.find(GlButton).exists()).toBe(true);
expect(wrapper.attributes('data-clipboard-text')).toBe('copy me');
expect(wrapper.find(Icon).props('name')).toBe('duplicate');
expect(wrapper.find(GlIcon).props('name')).toBe('copy-to-clipboard');
});
it('should have a tooltip with default values', () => {
......
......@@ -54,97 +54,6 @@ describe('date time picker lib', () => {
});
});
describe('getTimeWindow', () => {
[
{
args: [
{
start: '2019-10-01T18:27:47.000Z',
end: '2019-10-01T21:27:47.000Z',
},
dateTimePickerLib.defaultTimeWindows,
],
expected: 'threeHours',
},
{
args: [
{
start: '2019-10-01T28:27:47.000Z',
end: '2019-10-01T21:27:47.000Z',
},
dateTimePickerLib.defaultTimeWindows,
],
expected: null,
},
{
args: [
{
start: '',
end: '',
},
dateTimePickerLib.defaultTimeWindows,
],
expected: null,
},
{
args: [
{
start: null,
end: null,
},
dateTimePickerLib.defaultTimeWindows,
],
expected: null,
},
{
args: [{}, dateTimePickerLib.defaultTimeWindows],
expected: null,
},
].forEach(({ args, expected }) => {
it(`returns "${expected}" with args=${JSON.stringify(args)}`, () => {
expect(dateTimePickerLib.getTimeWindowKey(...args)).toEqual(expected);
});
});
});
describe('getTimeRange', () => {
function secondsBetween({ start, end }) {
return (new Date(end) - new Date(start)) / 1000;
}
function minutesBetween(timeRange) {
return secondsBetween(timeRange) / 60;
}
function hoursBetween(timeRange) {
return minutesBetween(timeRange) / 60;
}
it('defaults to an 8 hour (28800s) difference', () => {
const params = dateTimePickerLib.getTimeRange();
expect(hoursBetween(params)).toEqual(8);
});
it('accepts time window as an argument', () => {
const params = dateTimePickerLib.getTimeRange('thirtyMinutes');
expect(minutesBetween(params)).toEqual(30);
});
it('returns a value for every defined time window', () => {
const nonDefaultWindows = Object.entries(dateTimePickerLib.defaultTimeWindows).filter(
([, timeWindow]) => !timeWindow.default,
);
nonDefaultWindows.forEach(timeWindow => {
const params = dateTimePickerLib.getTimeRange(timeWindow[0]);
// Ensure we're not returning the default
expect(hoursBetween(params)).not.toEqual(8);
});
});
});
describe('stringToISODate', () => {
['', 'null', undefined, 'abc'].forEach(input => {
it(`throws error for invalid input like ${input}`, done => {
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::BackgroundMigration::UpdateExistingSubgroupToMatchVisibilityLevelOfParent, :migration, schema: 2020_01_10_121314 do
include MigrationHelpers::NamespacesHelpers
context 'private visibility level' do
it 'updates the project visibility' do
parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE)
child = create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id)
expect { subject.perform([parent.id], Gitlab::VisibilityLevel::PRIVATE) }.to change { child.reload.visibility_level }.to(Gitlab::VisibilityLevel::PRIVATE)
end
it 'updates sub-sub groups' do
parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE)
middle_group = create_namespace('middle', Gitlab::VisibilityLevel::PRIVATE, parent_id: parent.id)
child = create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
subject.perform([parent.id, middle_group.id], Gitlab::VisibilityLevel::PRIVATE)
expect(child.reload.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
it 'updates all sub groups' do
parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE)
middle_group = create_namespace('middle', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id)
child = create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
subject.perform([parent.id], Gitlab::VisibilityLevel::PRIVATE)
expect(child.reload.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
expect(middle_group.reload.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
end
context 'internal visibility level' do
it 'updates the project visibility' do
parent = create_namespace('parent', Gitlab::VisibilityLevel::INTERNAL)
child = create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id)
expect { subject.perform([parent.id], Gitlab::VisibilityLevel::INTERNAL) }.to change { child.reload.visibility_level }.to(Gitlab::VisibilityLevel::INTERNAL)
end
end
end
......@@ -128,4 +128,23 @@ describe Gitlab::Database::WithLockRetries do
end
end
end
context 'casting durations correctly' do
let(:timing_configuration) { [[0.015.seconds, 0.025.seconds], [0.015.seconds, 0.025.seconds]] } # 15ms, 25ms
it 'executes `SET LOCAL lock_timeout` using the configured timeout value in milliseconds' do
expect(ActiveRecord::Base.connection).to receive(:execute).with("SAVEPOINT active_record_1").and_call_original
expect(ActiveRecord::Base.connection).to receive(:execute).with("SET LOCAL lock_timeout TO '15ms'").and_call_original
expect(ActiveRecord::Base.connection).to receive(:execute).with("RELEASE SAVEPOINT active_record_1").and_call_original
subject.run { }
end
it 'calls `sleep` after the first iteration fails, using the configured sleep time' do
expect(subject).to receive(:run_block_with_transaction).and_raise(ActiveRecord::LockWaitTimeout).twice
expect(subject).to receive(:sleep).with(0.025)
subject.run { }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20200110121314_schedule_update_existing_subgroup_to_match_visibility_level_of_parent.rb')
describe ScheduleUpdateExistingSubgroupToMatchVisibilityLevelOfParent, :migration, :sidekiq do
include MigrationHelpers::NamespacesHelpers
let(:migration_class) { described_class::MIGRATION }
let(:migration_name) { migration_class.to_s.demodulize }
context 'private visibility level' do
it 'correctly schedules background migrations' do
parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE)
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id)
Sidekiq::Testing.fake! do
Timecop.freeze do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq(1)
expect(migration_name).to be_scheduled_migration_with_multiple_args([parent.id], Gitlab::VisibilityLevel::PRIVATE)
end
end
end
it 'correctly schedules background migrations for groups and subgroups' do
parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE)
middle_group = create_namespace('middle_group', Gitlab::VisibilityLevel::PRIVATE, parent_id: parent.id)
create_namespace('middle_empty_group', Gitlab::VisibilityLevel::PRIVATE, parent_id: parent.id)
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
Sidekiq::Testing.fake! do
Timecop.freeze do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq(1)
expect(migration_name).to be_scheduled_migration_with_multiple_args([middle_group.id, parent.id], Gitlab::VisibilityLevel::PRIVATE)
end
end
end
end
context 'internal visibility level' do
it 'correctly schedules background migrations' do
parent = create_namespace('parent', Gitlab::VisibilityLevel::INTERNAL)
middle_group = create_namespace('child', Gitlab::VisibilityLevel::INTERNAL, parent_id: parent.id)
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
Sidekiq::Testing.fake! do
Timecop.freeze do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq(1)
expect(migration_name).to be_scheduled_migration_with_multiple_args([parent.id, middle_group.id], Gitlab::VisibilityLevel::INTERNAL)
end
end
end
end
context 'mixed visibility levels' do
it 'correctly schedules background migrations' do
parent1 = create_namespace('parent1', Gitlab::VisibilityLevel::INTERNAL)
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent1.id)
parent2 = create_namespace('parent2', Gitlab::VisibilityLevel::PRIVATE)
middle_group = create_namespace('middle_group', Gitlab::VisibilityLevel::INTERNAL, parent_id: parent2.id)
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
Sidekiq::Testing.fake! do
Timecop.freeze do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq(2)
expect(migration_name).to be_scheduled_migration_with_multiple_args([parent1.id, middle_group.id], Gitlab::VisibilityLevel::INTERNAL)
expect(migration_name).to be_scheduled_migration_with_multiple_args([parent2.id], Gitlab::VisibilityLevel::PRIVATE)
end
end
end
end
end
......@@ -26,3 +26,26 @@ RSpec::Matchers.define :be_scheduled_migration do |*expected|
"Migration `#{migration}` with args `#{expected.inspect}` not scheduled!"
end
end
RSpec::Matchers.define :be_scheduled_migration_with_multiple_args do |*expected|
match do |migration|
BackgroundMigrationWorker.jobs.any? do |job|
args = job['args'].size == 1 ? [BackgroundMigrationWorker.jobs[0]['args'][0], []] : job['args']
args[0] == migration && compare_args(args, expected)
end
end
failure_message do |migration|
"Migration `#{migration}` with args `#{expected.inspect}` not scheduled!"
end
def compare_args(args, expected)
args[1].map.with_index do |arg, i|
arg.is_a?(Array) ? same_arrays?(arg, expected[i]) : arg == expected[i]
end.all?
end
def same_arrays?(arg, expected)
arg.sort == expected.sort
end
end
# frozen_string_literal: true
module MigrationHelpers
module NamespacesHelpers
def create_namespace(name, visibility, options = {})
table(:namespaces).create({
name: name,
path: name,
type: 'Group',
visibility_level: visibility
}.merge(options))
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment