Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-08-31 09:08:57 +00:00
parent 8d15913bc4
commit b46d41d54b
91 changed files with 802 additions and 192 deletions

View file

@ -1 +1 @@
40fae4205d3ad62ca9341620146486bee8d31b28
d924490032231edb9452acdaca7d8e4747cf6ab4

View file

@ -870,6 +870,14 @@ const Api = {
return axios.put(url, freezePeriod);
},
deleteFreezePeriod(id, freezePeriodId) {
const url = Api.buildUrl(this.freezePeriodPath)
.replace(':id', encodeURIComponent(id))
.replace(':freeze_period_id', encodeURIComponent(freezePeriodId));
return axios.delete(url);
},
trackRedisCounterEvent(event) {
if (!gon.features?.usageDataApi) {
return null;

View file

@ -1,5 +1,5 @@
<script>
import { GlTable, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { GlTable, GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { s__ } from '~/locale';
@ -21,21 +21,42 @@ export default {
key: 'edit',
label: s__('DeployFreeze|Edit'),
},
{
key: 'delete',
label: s__('DeployFreeze|Delete'),
},
],
translations: {
addDeployFreeze: s__('DeployFreeze|Add deploy freeze'),
deleteDeployFreezeTitle: s__('DeployFreeze|Delete deploy freeze?'),
deleteDeployFreezeMessage: s__(
'DeployFreeze|Deploy freeze from %{start} to %{end} in %{timezone} will be removed. Are you sure?',
),
emptyStateText: s__(
'DeployFreeze|No deploy freezes exist for this project. To add one, select %{strongStart}Add deploy freeze%{strongEnd}',
),
},
modal: {
id: 'deleteFreezePeriodModal',
actionPrimary: {
text: s__('DeployFreeze|Delete freeze period'),
attributes: { variant: 'danger', 'data-testid': 'modal-confirm' },
},
},
components: {
GlTable,
GlButton,
GlModal,
GlSprintf,
},
directives: {
GlModal: GlModalDirective,
},
data() {
return {
freezePeriodToDelete: null,
};
},
computed: {
...mapState(['freezePeriods']),
tableIsNotEmpty() {
@ -46,7 +67,14 @@ export default {
this.fetchFreezePeriods();
},
methods: {
...mapActions(['fetchFreezePeriods', 'setFreezePeriod']),
...mapActions(['fetchFreezePeriods', 'setFreezePeriod', 'deleteFreezePeriod']),
handleDeleteFreezePeriod(freezePeriod) {
this.freezePeriodToDelete = freezePeriod;
},
confirmDeleteFreezePeriod() {
this.deleteFreezePeriod(this.freezePeriodToDelete);
this.freezePeriodToDelete = null;
},
},
};
</script>
@ -72,6 +100,18 @@ export default {
@click="setFreezePeriod(item)"
/>
</template>
<template #cell(delete)="{ item }">
<gl-button
v-gl-modal="$options.modal.id"
category="secondary"
variant="danger"
icon="remove"
:aria-label="$options.modal.actionPrimary.text"
:loading="item.isDeleting"
data-testid="delete-deploy-freeze"
@click="handleDeleteFreezePeriod(item)"
/>
</template>
<template #empty>
<p data-testid="empty-freeze-periods" class="gl-text-center text-plain">
<gl-sprintf :message="$options.translations.emptyStateText">
@ -90,5 +130,24 @@ export default {
>
{{ $options.translations.addDeployFreeze }}
</gl-button>
<gl-modal
:title="$options.translations.deleteDeployFreezeTitle"
:modal-id="$options.modal.id"
:action-primary="$options.modal.actionPrimary"
static
@primary="confirmDeleteFreezePeriod"
>
<template v-if="freezePeriodToDelete">
<gl-sprintf :message="$options.translations.deleteDeployFreezeMessage">
<template #start>
<code>{{ freezePeriodToDelete.freezeStart }}</code>
</template>
<template #end>
<code>{{ freezePeriodToDelete.freezeEnd }}</code>
</template>
<template #timezone>{{ freezePeriodToDelete.cronTimezone.formattedTimezone }}</template>
</gl-sprintf>
</template>
</gl-modal>
</div>
</template>

View file

@ -52,6 +52,22 @@ export const updateFreezePeriod = (store) =>
}),
);
export const deleteFreezePeriod = ({ state, commit }, { id }) => {
commit(types.REQUEST_DELETE_FREEZE_PERIOD, id);
return Api.deleteFreezePeriod(state.projectId, id)
.then(() => commit(types.RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS, id))
.catch((e) => {
createFlash({
message: __('Error: Unable to delete deploy freeze'),
});
commit(types.RECEIVE_DELETE_FREEZE_PERIOD_ERROR, id);
// eslint-disable-next-line no-console
console.error('[gitlab] Unable to delete deploy freeze:', e);
});
};
export const fetchFreezePeriods = ({ commit, state }) => {
commit(types.REQUEST_FREEZE_PERIODS);

View file

@ -10,4 +10,8 @@ export const SET_SELECTED_ID = 'SET_SELECTED_ID';
export const SET_FREEZE_START_CRON = 'SET_FREEZE_START_CRON';
export const SET_FREEZE_END_CRON = 'SET_FREEZE_END_CRON';
export const REQUEST_DELETE_FREEZE_PERIOD = 'REQUEST_DELETE_FREEZE_PERIOD';
export const RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS = 'RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS';
export const RECEIVE_DELETE_FREEZE_PERIOD_ERROR = 'RECEIVE_DELETE_FREEZE_PERIOD_ERROR';
export const RESET_MODAL = 'RESET_MODAL';

View file

@ -1,15 +1,28 @@
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { secondsToHours } from '~/lib/utils/datetime_utility';
import * as types from './mutation_types';
const formatTimezoneName = (freezePeriod, timezoneList) =>
convertObjectPropsToCamelCase({
const formatTimezoneName = (freezePeriod, timezoneList) => {
const tz = timezoneList.find((timezone) => timezone.identifier === freezePeriod.cron_timezone);
return convertObjectPropsToCamelCase({
...freezePeriod,
cron_timezone: {
formattedTimezone: timezoneList.find((tz) => tz.identifier === freezePeriod.cron_timezone)
?.name,
formattedTimezone: tz && `[UTC ${secondsToHours(tz.offset)}] ${tz.name}`,
identifier: freezePeriod.cron_timezone,
},
});
};
const setFreezePeriodIsDeleting = (state, id, isDeleting) => {
const freezePeriod = state.freezePeriods.find((f) => f.id === id);
if (!freezePeriod) {
return;
}
Vue.set(freezePeriod, 'isDeleting', isDeleting);
};
export default {
[types.REQUEST_FREEZE_PERIODS](state) {
@ -53,6 +66,18 @@ export default {
state.selectedId = id;
},
[types.REQUEST_DELETE_FREEZE_PERIOD](state, id) {
setFreezePeriodIsDeleting(state, id, true);
},
[types.RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS](state, id) {
state.freezePeriods = state.freezePeriods.filter((f) => f.id !== id);
},
[types.RECEIVE_DELETE_FREEZE_PERIOD_ERROR](state, id) {
setFreezePeriodIsDeleting(state, id, false);
},
[types.RESET_MODAL](state) {
state.freezeStartCron = '';
state.freezeEndCron = '';

View file

@ -22,10 +22,6 @@ export default {
type: Object,
required: true,
},
canReadEnvironment: {
type: Boolean,
required: true,
},
},
methods: {
onChangePage(page) {
@ -42,7 +38,7 @@ export default {
<slot name="empty-state"></slot>
<div v-if="!isLoading && environments.length > 0" class="table-holder">
<environment-table :environments="environments" :can-read-environment="canReadEnvironment" />
<environment-table :environments="environments" />
<table-pagination
v-if="pagination && pagination.totalPages > 1"

View file

@ -48,12 +48,6 @@ export default {
mixins: [timeagoMixin],
props: {
canReadEnvironment: {
type: Boolean,
required: false,
default: false,
},
model: {
type: Object,
required: true,
@ -790,14 +784,14 @@ export default {
/>
<external-url-component
v-if="externalURL && canReadEnvironment"
v-if="externalURL"
:external-url="externalURL"
data-track-action="click_button"
data-track-label="environment_url"
/>
<monitoring-button-component
v-if="monitoringUrl && canReadEnvironment"
v-if="monitoringUrl"
:monitoring-url="monitoringUrl"
data-track-action="click_button"
data-track-label="environment_monitoring"

View file

@ -52,10 +52,6 @@ export default {
type: Boolean,
required: true,
},
canReadEnvironment: {
type: Boolean,
required: true,
},
newEnvironmentPath: {
type: String,
required: true,
@ -210,7 +206,6 @@ export default {
:is-loading="isLoading"
:environments="state.environments"
:pagination="state.paginationInformation"
:can-read-environment="canReadEnvironment"
@onChangePage="onChangePage"
>
<template v-if="!isLoading && state.environments.length === 0" #empty-state>

View file

@ -27,10 +27,6 @@ export default {
type: Object,
required: true,
},
canReadEnvironment: {
type: Boolean,
required: true,
},
canAdminEnvironment: {
type: Boolean,
required: true,
@ -84,7 +80,7 @@ export default {
return this.environment.isAvailable && Boolean(this.environment.autoStopAt);
},
shouldShowExternalUrlButton() {
return this.canReadEnvironment && Boolean(this.environment.externalUrl);
return Boolean(this.environment.externalUrl);
},
shouldShowStopButton() {
return this.canStopEnvironment && this.environment.isAvailable;
@ -138,7 +134,7 @@ export default {
>{{ $options.i18n.externalButtonText }}</gl-button
>
<gl-button
v-if="canReadEnvironment"
v-if="shouldShowExternalUrlButton"
data-testid="metrics-button"
:href="metricsPath"
:title="$options.i18n.metricsButtonTitle"

View file

@ -23,11 +23,6 @@ export default {
required: true,
default: () => [],
},
canReadEnvironment: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -155,7 +150,6 @@ export default {
<environment-item
:key="`environment-item-${i}`"
:model="model"
:can-read-environment="canReadEnvironment"
:table-data="tableData"
data-qa-selector="environment_item"
/>
@ -191,7 +185,6 @@ export default {
<environment-item
:key="`environment-row-${i}-${index}`"
:model="child"
:can-read-environment="canReadEnvironment"
:table-data="tableData"
data-qa-selector="environment_item"
/>

View file

@ -1,7 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '../../lib/utils/common_utils';
import Translate from '../../vue_shared/translate';
import environmentsFolderApp from './environments_folder_view.vue';
@ -31,7 +30,6 @@ export default () => {
endpoint: environmentsData.environmentsDataEndpoint,
folderName: environmentsData.environmentsDataFolderName,
cssContainerClass: environmentsData.cssClass,
canReadEnvironment: parseBoolean(environmentsData.environmentsDataCanReadEnvironment),
};
},
render(createElement) {
@ -40,7 +38,6 @@ export default () => {
endpoint: this.endpoint,
folderName: this.folderName,
cssContainerClass: this.cssContainerClass,
canReadEnvironment: this.canReadEnvironment,
},
});
},

View file

@ -30,10 +30,6 @@ export default {
required: false,
default: '',
},
canReadEnvironment: {
type: Boolean,
required: true,
},
},
methods: {
successCallback(resp) {
@ -72,7 +68,6 @@ export default {
:is-loading="isLoading"
:environments="state.environments"
:pagination="state.paginationInformation"
:can-read-environment="canReadEnvironment"
@onChangePage="onChangePage"
/>
</div>

View file

@ -32,7 +32,6 @@ export default () => {
newEnvironmentPath: environmentsData.newEnvironmentPath,
helpPagePath: environmentsData.helpPagePath,
canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment),
canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment),
};
},
render(createElement) {
@ -42,7 +41,6 @@ export default () => {
newEnvironmentPath: this.newEnvironmentPath,
helpPagePath: this.helpPagePath,
canCreateEnvironment: this.canCreateEnvironment,
canReadEnvironment: this.canReadEnvironment,
},
});
},

View file

@ -36,7 +36,6 @@ export const initHeader = () => {
environment: this.environment,
canDestroyEnvironment: dataset.canDestroyEnvironment,
canUpdateEnvironment: dataset.canUpdateEnvironment,
canReadEnvironment: dataset.canReadEnvironment,
canStopEnvironment: dataset.canStopEnvironment,
canAdminEnvironment: dataset.canAdminEnvironment,
cancelAutoStopPath: dataset.environmentCancelAutoStopPath,

View file

@ -0,0 +1,19 @@
<script>
export default {
props: {
url: {
type: String,
required: true,
},
alt: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="gl-text-center gl-p-7 gl-bg-gray-50">
<img :src="url" :alt="alt" data-testid="image" />
</div>
</template>

View file

@ -6,6 +6,8 @@ export const loadViewer = (type) => {
return () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue');
case 'download':
return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue');
case 'image':
return () => import(/* webpackChunkName: 'blob_image_viewer' */ './image_viewer.vue');
default:
return null;
}
@ -23,5 +25,9 @@ export const viewerProps = (type, blob) => {
filePath: blob.rawPath,
fileSize: blob.rawSize,
},
image: {
url: blob.rawPath,
alt: blob.name,
},
}[type];
};

View file

@ -2,7 +2,7 @@ export default (search = '') => {
const highlightLineClass = 'hll';
const contentBody = document.getElementById('content-body');
const searchTerm = search.toLowerCase();
const blobs = contentBody.querySelectorAll('.blob-result');
const blobs = contentBody.querySelectorAll('.js-blob-result');
blobs.forEach((blob) => {
const lines = blob.querySelectorAll('.line');

View file

@ -27,9 +27,7 @@ module Boards
list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params)
issues = issues_from(list_service)
if Gitlab::Database.read_write? && !board.disabled_for?(current_user)
Issue.move_nulls_to_end(issues)
end
::Boards::Issues::ListService.initialize_relative_positions(board, current_user, issues)
render_issues(issues, list_service.metadata)
end

View file

@ -21,9 +21,10 @@ class RegistrationsController < Devise::RegistrationsController
def create
set_user_state
accept_pending_invitations
super do |new_user|
accept_pending_invitations if new_user.persisted?
persist_accepted_terms_if_required(new_user)
set_role_required(new_user)

View file

@ -15,8 +15,11 @@ module Resolvers
def resolve(**args)
filter_params = item_filters(args[:filters]).merge(board_id: list.board.id, id: list.id)
service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
pagination_connections = Gitlab::Graphql::Pagination::Keyset::Connection.new(service.execute)
service.execute
::Boards::Issues::ListService.initialize_relative_positions(list.board, current_user, pagination_connections.items)
pagination_connections
end
# https://gitlab.com/gitlab-org/gitlab/-/issues/235681

View file

@ -52,15 +52,22 @@ module Types
# rubocop: disable CodeReuse/ActiveRecord
def jobs_for_pipeline(pipeline, stage_ids, include_needs)
builds_results = pipeline.latest_builds.where(stage_id: stage_ids).preload(:job_artifacts, :project)
bridges_results = pipeline.bridges.where(stage_id: stage_ids).preload(:project)
builds_results = builds_results.preload(:needs) if include_needs
bridges_results = bridges_results.preload(:needs) if include_needs
commit_status_results = pipeline.latest_statuses.where(stage_id: stage_ids)
jobs = pipeline.statuses.latest.where(stage_id: stage_ids)
results = builds_results | bridges_results | commit_status_results
common_relations = [:project]
common_relations << :needs if include_needs
results.group_by(&:stage_id)
preloaders = {
::Ci::Build => [:metadata, :job_artifacts],
::Ci::Bridge => [:metadata, :downstream_pipeline],
::GenericCommitStatus => []
}
preloaders.each do |klass, relations|
ActiveRecord::Associations::Preloader.new.preload(jobs.select { |job| job.is_a?(klass) }, relations + common_relations)
end
jobs.group_by(&:stage_id)
end
# rubocop: enable CodeReuse/ActiveRecord
end

View file

@ -73,7 +73,6 @@ module EnvironmentHelper
external_url: environment.external_url,
can_update_environment: can?(current_user, :update_environment, environment),
can_destroy_environment: can_destroy_environment?(environment),
can_read_environment: can?(current_user, :read_environment, environment),
can_stop_environment: can?(current_user, :stop_environment, environment),
can_admin_environment: can?(current_user, :admin_environment, project),
environment_metrics_path: environment_metrics_path(environment),

View file

@ -66,6 +66,7 @@ module Ci
has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
has_many :bridges, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
has_many :generic_commit_statuses, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'GenericCommitStatus'
has_many :job_artifacts, through: :builds
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent
has_many :variables, class_name: 'Ci::PipelineVariable'

View file

@ -14,12 +14,10 @@ module CronSchedulable
# The `next_run_at` column is set to the actual execution date of worker that
# triggers the schedule. This way, a schedule like `*/1 * * * *` won't be triggered
# in a short interval when the worker runs irregularly by Sidekiq Memory Killer.
def calculate_next_run_at
now = Time.zone.now
def calculate_next_run_at(start_time = Time.zone.now)
ideal_next_run = ideal_next_run_from(start_time)
ideal_next_run = ideal_next_run_from(now)
if ideal_next_run == cron_worker_next_run_from(now)
if ideal_next_run == cron_worker_next_run_from(start_time)
ideal_next_run
else
cron_worker_next_run_from(ideal_next_run)

View file

@ -278,12 +278,14 @@ class Member < ApplicationRecord
def accept_invite!(new_user)
return false unless invite?
return false unless new_user
self.user = new_user
return false unless self.user.save
self.invite_token = nil
self.invite_accepted_at = Time.current.utc
self.user = new_user
saved = self.save
after_accept_invite if saved

View file

@ -582,7 +582,7 @@ class Note < ApplicationRecord
end
def post_processed_cache_key
cache_key_items = [cache_key, author.cache_key]
cache_key_items = [cache_key, author&.cache_key]
cache_key_items << Digest::SHA1.hexdigest(redacted_note_html) if redacted_note_html.present?
cache_key_items.join(':')

View file

@ -12,11 +12,11 @@ module Ci
erased_by.name if erased_by_user?
end
def status_title
def status_title(status = detailed_status)
if auto_canceled?
"Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
else
tooltip_for_badge
tooltip_for_badge(status)
end
end
@ -41,8 +41,8 @@ module Ci
private
def tooltip_for_badge
detailed_status.badge_tooltip.capitalize
def tooltip_for_badge(status)
status.badge_tooltip.capitalize
end
def detailed_status

View file

@ -15,18 +15,23 @@ module Ci
private
def preload_statuses(statuses)
loaded_statuses = statuses.load
statuses.tap do |statuses|
# rubocop: disable CodeReuse/ActiveRecord
ActiveRecord::Associations::Preloader.new.preload(preloadable_statuses(loaded_statuses), %w[pipeline tags job_artifacts_archive metadata])
# rubocop: enable CodeReuse/ActiveRecord
end
end
common_relations = [:pipeline]
def preloadable_statuses(statuses)
statuses.reject do |status|
status.instance_of?(::GenericCommitStatus) || status.instance_of?(::Ci::Bridge)
preloaders = {
::Ci::Build => [:metadata, :tags, :job_artifacts_archive],
::Ci::Bridge => [:metadata, :downstream_pipeline],
::GenericCommitStatus => []
}
# rubocop: disable CodeReuse/ActiveRecord
preloaders.each do |klass, relations|
ActiveRecord::Associations::Preloader
.new
.preload(statuses.select { |job| job.is_a?(klass) }, relations + common_relations)
end
# rubocop: enable CodeReuse/ActiveRecord
statuses
end
end
end

View file

@ -9,6 +9,14 @@ module Boards
IssuesFinder.valid_params
end
# It is a class method because we cannot apply it
# prior to knowing how many items should be fetched for a list.
def self.initialize_relative_positions(board, current_user, issues)
if Gitlab::Database.read_write? && !board.disabled_for?(current_user)
Issue.move_nulls_to_end(issues)
end
end
private
def order(items)

View file

@ -0,0 +1,31 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Dast profile schedule cadence schema",
"type": "object",
"anyOf": [
{
"properties": {
"unit": { "enum": ["day"] },
"duration": { "enum": [1] }
}
},
{
"properties": {
"unit": { "enum": ["week"] },
"duration": { "enum": [1] }
}
},
{
"properties": {
"unit": { "enum": ["month"] },
"duration": { "enum": [1, 3 ,6] }
}
},
{
"properties": {
"unit": { "enum": ["year"] },
"duration": { "enum": [1] }
}
}
]
}

View file

@ -52,7 +52,7 @@
.float-right
= form_for project.runner_projects.new, url: admin_namespace_project_runner_projects_path(project.namespace, project), method: :post do |f|
= f.hidden_field :runner_id, value: @runner.id
= f.submit _('Enable'), class: 'gl-button btn btn-sm'
= f.submit _('Enable'), class: 'gl-button btn btn-sm', data: { confirm: (s_('Runners|You are about to change this instance runner to a project runner. This operation is not reversible. Are you sure you want to continue?') if @runner.instance_type?) }
= paginate_without_count @projects
.col-md-6

View file

@ -7,10 +7,14 @@
- pipeline_link = local_assigns.fetch(:pipeline_link, false)
- stage = local_assigns.fetch(:stage, false)
- allow_retry = local_assigns.fetch(:allow_retry, false)
-# This prevents initializing another Ci::Status object where 'status' is used
- status = job.detailed_status(current_user)
%tr.build.commit{ class: ('retried' if retried) }
%td.status
= render "ci/status/badge", status: job.detailed_status(current_user), title: job.status_title
-# Sending 'status' prevents calling the user relation inside the presenter, generating N+1,
-# see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68743
= render "ci/status/badge", status: status, title: job.status_title(status)
%td
- if can?(current_user, :read_build, job)

View file

@ -1,4 +1,4 @@
.blob-result.gl-mt-3.gl-mb-5{ data: { qa_selector: 'result_item_content' } }
.js-blob-result.gl-mt-3.gl-mb-5{ data: { qa_selector: 'result_item_content' } }
.file-holder.file-holder-top-border
.js-file-title.file-title{ data: { qa_selector: 'file_title_content' } }
= link_to blob_link, data: {track_event: 'click_text', track_label: 'blob_path', track_property: 'search_result'} do

View file

@ -0,0 +1,8 @@
---
name: method_instrumentation_disable_initialization
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69091
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/339665
milestone: '14.3'
type: development
group: group::memory
default_enabled: false

View file

@ -178,27 +178,33 @@ if Gitlab::Metrics.enabled? && !Rails.env.test? && !(Rails.env.development? && d
ActiveRecord::Querying.public_instance_methods(false).map(&:to_s)
)
Gitlab::Metrics::Instrumentation
.instrument_class_hierarchy(ActiveRecord::Base) do |klass, method|
# Instrumenting the ApplicationSetting class can lead to an infinite
# loop. Since the data is cached any way we don't really need to
# instrument it.
if klass == ApplicationSetting
false
else
loc = method.source_location
# We are removing the Instrumentation module entirely in steps.
# More in https://gitlab.com/gitlab-org/gitlab/-/issues/217978.
unless ::Feature.enabled?(:method_instrumentation_disable_initialization)
Gitlab::Metrics::Instrumentation
.instrument_class_hierarchy(ActiveRecord::Base) do |klass, method|
# Instrumenting the ApplicationSetting class can lead to an infinite
# loop. Since the data is cached any way we don't really need to
# instrument it.
if klass == ApplicationSetting
false
else
loc = method.source_location
loc && loc[0].start_with?(models_path) && method.source =~ regex
loc && loc[0].start_with?(models_path) && method.source =~ regex
end
end
end
# Ability is in app/models, is not an ActiveRecord model, but should still
# be instrumented.
Gitlab::Metrics::Instrumentation.instrument_methods(Ability)
# Ability is in app/models, is not an ActiveRecord model, but should still
# be instrumented.
Gitlab::Metrics::Instrumentation.instrument_methods(Ability)
end
end
Gitlab::Metrics::Instrumentation.configure do |config|
instrument_classes(config)
unless ::Feature.enabled?(:method_instrumentation_disable_initialization)
Gitlab::Metrics::Instrumentation.configure do |config|
instrument_classes(config)
end
end
GC::Profiler.enable

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddCadenceToDastProfileSchedules < ActiveRecord::Migration[6.1]
def change
add_column :dast_profile_schedules, :cadence, :jsonb, null: false, default: {}
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
class AddTimezoneToDastProfileSchedules < ActiveRecord::Migration[6.1]
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
# We disable these cops here because adding the column is safe. The table does not
# have any data in it as it's behind a feature flag.
# rubocop: disable Rails/NotNullColumn
def up
execute('DELETE FROM dast_profile_schedules')
unless column_exists?(:dast_profile_schedules, :timezone)
add_column :dast_profile_schedules, :timezone, :text, null: false
end
add_text_limit :dast_profile_schedules, :timezone, 255
end
def down
return unless column_exists?(:dast_profile_schedules, :timezone)
remove_column :dast_profile_schedules, :timezone
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddStartsAtToDastProfileSchedules < ActiveRecord::Migration[6.1]
def change
add_column :dast_profile_schedules, :starts_at, :datetime_with_timezone, null: false, default: -> { 'NOW()' }
end
end

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
# See https://docs.gitlab.com/ee/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddUniqueIndexOnDastProfileToDastProfileSchedules < ActiveRecord::Migration[6.1]
include Gitlab::Database::MigrationHelpers
INDEX_NAME = 'index_dast_profile_schedules_on_dast_profile_id'
TABLE = :dast_profile_schedules
# We disable these cops here because changing this index is safe. The table does not
# have any data in it as it's behind a feature flag.
# rubocop: disable Migration/AddIndex
# rubocop: disable Migration/RemoveIndex
def up
execute('DELETE FROM dast_profile_schedules')
if index_exists_by_name?(TABLE, INDEX_NAME)
remove_index TABLE, :dast_profile_id, name: INDEX_NAME
end
unless index_exists_by_name?(TABLE, INDEX_NAME)
add_index TABLE, :dast_profile_id, unique: true, name: INDEX_NAME
end
end
def down
execute('DELETE FROM dast_profile_schedules')
if index_exists_by_name?(TABLE, INDEX_NAME)
remove_index TABLE, :dast_profile_id, name: INDEX_NAME
end
unless index_exists_by_name?(TABLE, INDEX_NAME)
add_index TABLE, :dast_profile_id
end
end
end

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true
# See https://docs.gitlab.com/ee/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class RemoveProjectProfileCompoundIndexFromDastProfileSchedules < ActiveRecord::Migration[6.1]
include Gitlab::Database::MigrationHelpers
TABLE = :dast_profile_schedules
INDEX_NAME = 'index_dast_profile_schedules_on_project_id_and_dast_profile_id'
# We disable these cops here because changing this index is safe. The table does not
# have any data in it as it's behind a feature flag.
# rubocop: disable Migration/AddIndex
# rubocop: disable Migration/RemoveIndex
def up
execute('DELETE FROM dast_profile_schedules')
if index_exists_by_name?(TABLE, INDEX_NAME)
remove_index TABLE, %i[project_id dast_profile_id], name: INDEX_NAME
end
end
def down
execute('DELETE FROM dast_profile_schedules')
unless index_exists_by_name?(TABLE, INDEX_NAME)
add_index TABLE, %i[project_id dast_profile_id], unique: true, name: INDEX_NAME
end
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
# See https://docs.gitlab.com/ee/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddIndexProjectIdOnDastProfileSchedule < ActiveRecord::Migration[6.1]
# We disable these cops here because changing this index is safe. The table does not
# have any data in it as it's behind a feature flag.
# rubocop: disable Migration/AddIndex
def change
add_index :dast_profile_schedules, :project_id
end
end

View file

@ -0,0 +1 @@
30e1463616c60b92afb28bbb76e3c55830a385af6df0e60e16ed96d9e75943b9

View file

@ -0,0 +1 @@
7e9b39914ade766357751953a4981225dbae7e5d371d4824af61b01af70f46ae

View file

@ -0,0 +1 @@
a2454f9fca3b1cedf7a0f2288b69abe799fe1f9ff4e2fe26d2cadfdddea73a83

View file

@ -0,0 +1 @@
d1ad234656f49861d2ca7694d23116e930bba597fca32b1015db698cc23bdc1c

View file

@ -0,0 +1 @@
23becdc9ad558882f4ce42e76391cdc2f760322a09c998082465fcb6d29dfeb5

View file

@ -0,0 +1 @@
9c5114dac05e90c15567bb3274f20f03a82f9e4d73d5c72d89c26bc9d742cc35

View file

@ -12089,7 +12089,11 @@ CREATE TABLE dast_profile_schedules (
updated_at timestamp with time zone NOT NULL,
active boolean DEFAULT true NOT NULL,
cron text NOT NULL,
CONSTRAINT check_86531ea73f CHECK ((char_length(cron) <= 255))
cadence jsonb DEFAULT '{}'::jsonb NOT NULL,
timezone text NOT NULL,
starts_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT check_86531ea73f CHECK ((char_length(cron) <= 255)),
CONSTRAINT check_be4d1c3af1 CHECK ((char_length(timezone) <= 255))
);
COMMENT ON TABLE dast_profile_schedules IS '{"owner":"group::dynamic analysis","description":"Scheduling for scans using DAST Profiles"}';
@ -23812,9 +23816,9 @@ CREATE UNIQUE INDEX index_daily_build_group_report_results_unique_columns ON ci_
CREATE INDEX index_dast_profile_schedules_active_next_run_at ON dast_profile_schedules USING btree (active, next_run_at);
CREATE INDEX index_dast_profile_schedules_on_dast_profile_id ON dast_profile_schedules USING btree (dast_profile_id);
CREATE UNIQUE INDEX index_dast_profile_schedules_on_dast_profile_id ON dast_profile_schedules USING btree (dast_profile_id);
CREATE UNIQUE INDEX index_dast_profile_schedules_on_project_id_and_dast_profile_id ON dast_profile_schedules USING btree (project_id, dast_profile_id);
CREATE INDEX index_dast_profile_schedules_on_project_id ON dast_profile_schedules USING btree (project_id);
CREATE INDEX index_dast_profile_schedules_on_user_id ON dast_profile_schedules USING btree (user_id);

View file

@ -95,7 +95,7 @@ GitLab provides a series of [CI templates that you can include in your project](
To automate deployments of your application to your [Amazon Elastic Container Service](https://aws.amazon.com/ecs/) (AWS ECS)
cluster, you can `include` the `AWS/Deploy-ECS.gitlab-ci.yml` template in your `.gitlab-ci.yml` file.
GitLab also provides [Docker images](https://gitlab.com/gitlab-org/cloud-deploy/-/tree/master/aws) that can be used in your `gitlab-ci.yml` file to simplify working with AWS:
GitLab also provides [Docker images](https://gitlab.com/gitlab-org/cloud-deploy/-/tree/master/aws) that can be used in your `.gitlab-ci.yml` file to simplify working with AWS:
- Use `registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest` to use AWS CLI commands.
- Use `registry.gitlab.com/gitlab-org/cloud-deploy/aws-ecs:latest` to deploy your application to AWS ECS.

View file

@ -136,10 +136,10 @@ connect the CD project to your development projects by using [multi-project pipe
A `.gitlab-ci.yml` may contain rules to deploy an application to the production server. This
deployment usually runs automatically after pushing a merge request. To prevent developers from
changing the `gitlab-ci.yml`, you can define it in a different repository. The configuration can
changing the `.gitlab-ci.yml`, you can define it in a different repository. The configuration can
reference a file in another project with a completely different set of permissions (similar to
[separating a project for deployments](#separate-project-for-deployments)).
In this scenario, the `gitlab-ci.yml` is publicly accessible, but can only be edited by users with
In this scenario, the `.gitlab-ci.yml` is publicly accessible, but can only be edited by users with
appropriate permissions in the other project.
For more information, see [Custom CI/CD configuration path](../pipelines/settings.md#specify-a-custom-cicd-configuration-file).

View file

@ -135,5 +135,5 @@ to switch to a different deployment. Both deployments are running in parallel, a
can be switched to at any time.
An [example deployable application](https://gitlab.com/gl-release/blue-green-example)
is available, with a [`gitlab-ci.yml` CI/CD configuration file](https://gitlab.com/gl-release/blue-green-example/blob/master/.gitlab-ci.yml)
is available, with a [`.gitlab-ci.yml` CI/CD configuration file](https://gitlab.com/gl-release/blue-green-example/blob/master/.gitlab-ci.yml)
that demonstrates blue-green deployments.

View file

@ -177,7 +177,7 @@ You can find the play button in the pipelines, environments, deployments, and jo
If you are deploying to a [Kubernetes cluster](../../user/project/clusters/index.md)
associated with your project, you can configure these deployments from your
`gitlab-ci.yml` file.
`.gitlab-ci.yml` file.
NOTE:
Kubernetes configuration isn't supported for Kubernetes clusters that are

View file

@ -251,7 +251,7 @@ To protect a group-level environment:
1. Make sure your environments have the correct
[`deployment_tier`](index.md#deployment-tier-of-environments) defined in
`gitlab-ci.yml`.
`.gitlab-ci.yml`.
1. Configure the group-level protected environments via the
[REST API](../../api/group_protected_environments.md).

View file

@ -56,7 +56,7 @@ reflected in the CI lint. It displays the same results as the existing [CI Lint
> - [Moved to **CI/CD > Editor**](https://gitlab.com/gitlab-org/gitlab/-/issues/263141) in GitLab 13.7.
> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/290117) in GitLab 13.12.
To view a visualization of your `gitlab-ci.yml` configuration, in your project,
To view a visualization of your `.gitlab-ci.yml` configuration, in your project,
go to **CI/CD > Editor**, and then select the **Visualize** tab. The
visualization shows all stages and jobs. Any [`needs`](../yaml/index.md#needs)
relationships are displayed as lines connecting jobs together, showing the

View file

@ -29,7 +29,7 @@ with your editor of choice.
### Verify syntax with CI Lint tool
The [CI Lint tool](lint.md) is a simple way to ensure the syntax of a CI/CD configuration
file is correct. Paste in full `gitlab-ci.yml` files or individual jobs configuration,
file is correct. Paste in full `.gitlab-ci.yml` files or individual jobs configuration,
to verify the basic syntax.
When a `.gitlab-ci.yml` file is present in a project, you can also use the CI Lint
@ -49,7 +49,7 @@ and check if their values are what you expect.
## GitLab CI/CD documentation
The [complete `gitlab-ci.yml` reference](yaml/index.md) contains a full list of
The [complete `.gitlab-ci.yml` reference](yaml/index.md) contains a full list of
every keyword you may need to use to configure your pipelines.
You can also look at a large number of pipeline configuration [examples](examples/index.md)

View file

@ -386,7 +386,7 @@ does not block triggered pipelines.
> [Moved](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/42861) to GitLab Free in 11.4.
Use `include` to include external YAML files in your CI/CD configuration.
You can break down one long `gitlab-ci.yml` file into multiple files to increase readability,
You can break down one long `.gitlab-ci.yml` file into multiple files to increase readability,
or reduce duplication of the same configuration in multiple places.
You can also store template files in a central repository and `include` them in projects.
@ -4483,7 +4483,7 @@ deploy_review_job:
You can use only integers and strings for the variable's name and value.
If you define a variable at the top level of the `gitlab-ci.yml` file, it is global,
If you define a variable at the top level of the `.gitlab-ci.yml` file, it is global,
meaning it applies to all jobs. If you define a variable in a job, it's available
to that job only.

View file

@ -98,6 +98,7 @@ EE: true
database records created during Cycle Analytics model spec."
- _Any_ contribution from a community member, no matter how small, **may** have
a changelog entry regardless of these guidelines if the contributor wants one.
- Any [GLEX experiment](experiment_guide/gitlab_experiment.md) changes **should not** have a changelog entry.
- [Removing](feature_flags/#changelog) a feature flag, when the new code is retained.
## Writing good changelog entries

View file

@ -74,7 +74,7 @@ If your application utilizes Docker containers you have another option for deplo
After your Docker build job completes and your image is added to your container registry, you can use the image as a
[service](../../../ci/services/index.md).
By using service definitions in your `gitlab-ci.yml`, you can scan services with the DAST analyzer.
By using service definitions in your `.gitlab-ci.yml`, you can scan services with the DAST analyzer.
```yaml
stages:
@ -1307,9 +1307,9 @@ dast:
By default, DAST downloads all artifacts defined by previous jobs in the pipeline. If
your DAST job does not rely on `environment_url.txt` to define the URL under test or any other files created
in previous jobs, we recommend you don't download artifacts. To avoid downloading
artifacts, add the following to your `gitlab-ci.yml` file:
artifacts, add the following to your `.gitlab-ci.yml` file:
```json
```yaml
dast:
dependencies: []
```

View file

@ -111,7 +111,7 @@ example of such a transfer:
GitLab provides a [vendored template](../../../ci/yaml/index.md#includetemplate)
to ease this process.
This template should be used in a new, empty project, with a `gitlab-ci.yml` file containing:
This template should be used in a new, empty project, with a `.gitlab-ci.yml` file containing:
```yaml
include:

View file

@ -316,7 +316,7 @@ The optional `runtime` parameter can refer to one of the following runtime alias
| `openfaas/classic/python3` | OpenFaaS |
| `openfaas/classic/ruby` | OpenFaaS |
After the `gitlab-ci.yml` template has been added and the `serverless.yml` file
After the `.gitlab-ci.yml` template has been added and the `serverless.yml` file
has been created, pushing a commit to your project results in a CI pipeline
being executed which deploys each function as a Knative service. After the
deploy stage has finished, additional details for the function display

View file

@ -129,7 +129,7 @@ The `source` is ignored if the path does not follow this pattern. The parser ass
### JavaScript example
The following [`gitlab-ci.yml`](../../../ci/yaml/index.md) example uses [Mocha](https://mochajs.org/)
The following [`.gitlab-ci.yml`](../../../ci/yaml/index.md) example uses [Mocha](https://mochajs.org/)
JavaScript testing and [nyc](https://github.com/istanbuljs/nyc) coverage-tooling to
generate the coverage artifact:
@ -147,7 +147,7 @@ test:
#### Maven example
The following [`gitlab-ci.yml`](../../../ci/yaml/index.md) example for Java or Kotlin uses [Maven](https://maven.apache.org/)
The following [`.gitlab-ci.yml`](../../../ci/yaml/index.md) example for Java or Kotlin uses [Maven](https://maven.apache.org/)
to build the project and [JaCoCo](https://www.eclemma.org/jacoco/) coverage-tooling to
generate the coverage artifact.
You can check the [Docker image configuration and scripts](https://gitlab.com/haynes/jacoco2cobertura) if you want to build your own image.
@ -185,7 +185,7 @@ coverage-jdk11:
#### Gradle example
The following [`gitlab-ci.yml`](../../../ci/yaml/index.md) example for Java or Kotlin uses [Gradle](https://gradle.org/)
The following [`.gitlab-ci.yml`](../../../ci/yaml/index.md) example for Java or Kotlin uses [Gradle](https://gradle.org/)
to build the project and [JaCoCo](https://www.eclemma.org/jacoco/) coverage-tooling to
generate the coverage artifact.
You can check the [Docker image configuration and scripts](https://gitlab.com/haynes/jacoco2cobertura) if you want to build your own image.
@ -223,7 +223,7 @@ coverage-jdk11:
### Python example
The following [`gitlab-ci.yml`](../../../ci/yaml/index.md) example for Python uses [pytest-cov](https://pytest-cov.readthedocs.io/) to collect test coverage data and [coverage.py](https://coverage.readthedocs.io/) to convert the report to use full relative paths.
The following [`.gitlab-ci.yml`](../../../ci/yaml/index.md) example for Python uses [pytest-cov](https://pytest-cov.readthedocs.io/) to collect test coverage data and [coverage.py](https://coverage.readthedocs.io/) to convert the report to use full relative paths.
The information isn't displayed without the conversion.
This example assumes that the code for your package is in `src/` and your tests are in `tests.py`:
@ -243,7 +243,7 @@ run tests:
### C/C++ example
The following [`gitlab-ci.yml`](../../../ci/yaml/index.md) example for C/C++ with
The following [`.gitlab-ci.yml`](../../../ci/yaml/index.md) example for C/C++ with
`gcc` or `g++` as the compiler uses [`gcovr`](https://gcovr.com/en/stable/) to generate the coverage
output file in Cobertura XML format.

View file

@ -46,7 +46,7 @@ To create a GitLab Pages website:
| Document | Description |
| -------- | ----------- |
| [Create a `gitlab-ci.yml` file from scratch](getting_started/pages_from_scratch.md) | Add a Pages site to an existing project. Learn how to create and configure your own CI file. |
| [Create a `.gitlab-ci.yml` file from scratch](getting_started/pages_from_scratch.md) | Add a Pages site to an existing project. Learn how to create and configure your own CI file. |
| [Use a `.gitlab-ci.yml` template](getting_started/pages_ci_cd_template.md) | Add a Pages site to an existing project. Use a pre-populated CI template file. |
| [Fork a sample project](getting_started/pages_forked_sample_project.md) | Create a new project with Pages already configured by forking a sample project. |
| [Use a project template](getting_started/pages_new_project_template.md) | Create a new project with Pages already configured by using a template. |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -186,7 +186,8 @@ To subscribe to notifications for releases:
## Prevent unintentional releases by setting a deploy freeze
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29382) in GitLab 13.0.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29382) in GitLab 13.0.
> - The ability to delete freeze periods through the UI was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/212451) in GitLab 14.3.
Prevent unintended production releases during a period of time you specify by
setting a [*deploy freeze* period](../../../ci/environments/deployment_safety.md).
@ -199,7 +200,7 @@ If the job that's executing is within a freeze period, GitLab CI/CD creates an e
variable named `$CI_DEPLOY_FREEZE`.
To prevent the deployment job from executing, create a `rules` entry in your
`gitlab-ci.yml`, for example:
`.gitlab-ci.yml`, for example:
```yaml
deploy_to_production:
@ -219,11 +220,8 @@ To set a deploy freeze window in the UI, complete these steps:
1. Click **Add deploy freeze** to open the deploy freeze modal.
1. Enter the start time, end time, and timezone of the desired deploy freeze period.
1. Click **Add deploy freeze** in the modal.
1. After the deploy freeze is saved, you can edit it by selecting the edit button (**{pencil}**).
![Deploy freeze modal for setting a deploy freeze period](img/deploy_freeze_v13_10.png)
WARNING:
To delete a deploy freeze, use the [Freeze Periods API](../../../api/freeze_periods.md).
1. After the deploy freeze is saved, you can edit it by selecting the edit button (**{pencil}**) and remove it by selecting the delete button (**{remove}**).
![Deploy freeze modal for setting a deploy freeze period](img/deploy_freeze_v14_3.png)
If a project contains multiple freeze periods, all periods apply. If they overlap, the freeze covers the
complete overlapping period.

View file

@ -6,8 +6,40 @@ module Gitlab
VALID_SYNTAX_SAMPLE_TIME_ZONE = 'UTC'
VALID_SYNTAX_SAMPLE_CRON = '* * * * *'
def self.parse_natural(expression, cron_timezone = 'UTC')
new(Fugit::Nat.parse(expression)&.original, cron_timezone)
class << self
def parse_natural(expression, cron_timezone = 'UTC')
new(Fugit::Nat.parse(expression)&.original, cron_timezone)
end
# This method generates compatible expressions that can be
# parsed by Fugit::Nat.parse to generate a cron line.
# It takes start date of the cron and cadence in the following format:
# cadence = {
# unit: 'day/week/month/year'
# duration: 1
# }
def parse_natural_with_timestamp(starts_at, cadence)
case cadence[:unit]
when 'day' # Currently supports only 'every 1 day'.
"#{starts_at.min} #{starts_at.hour} * * *"
when 'week' # Currently supports only 'every 1 week'.
"#{starts_at.min} #{starts_at.hour} * * #{starts_at.wday}"
when 'month'
unless [1, 3, 6, 12].include?(cadence[:duration])
raise NotImplementedError, "The cadence #{cadence} is not supported"
end
"#{starts_at.min} #{starts_at.hour} #{starts_at.mday} #{fall_in_months(cadence[:duration], starts_at)} *"
when 'year' # Currently supports only 'every 1 year'.
"#{starts_at.min} #{starts_at.hour} #{starts_at.mday} #{starts_at.month} *"
else
raise NotImplementedError, "The cadence unit #{cadence[:unit]} is not implemented"
end
end
def fall_in_months(offset, start_date)
(1..(12 / offset)).map { |i| start_date.next_month(offset * i).month }.join(',')
end
end
def initialize(cron, cron_timezone = 'UTC')

View file

@ -11068,6 +11068,18 @@ msgstr ""
msgid "DeployFreeze|Add deploy freeze"
msgstr ""
msgid "DeployFreeze|Delete"
msgstr ""
msgid "DeployFreeze|Delete deploy freeze?"
msgstr ""
msgid "DeployFreeze|Delete freeze period"
msgstr ""
msgid "DeployFreeze|Deploy freeze from %{start} to %{end} in %{timezone} will be removed. Are you sure?"
msgstr ""
msgid "DeployFreeze|Edit"
msgstr ""
@ -13262,6 +13274,9 @@ msgstr ""
msgid "Error: Unable to create deploy freeze"
msgstr ""
msgid "Error: Unable to delete deploy freeze"
msgstr ""
msgid "Error: Unable to find AWS role for current user"
msgstr ""
@ -28991,6 +29006,9 @@ msgstr ""
msgid "Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot. Default choice for Windows Shell executor."
msgstr ""
msgid "Runners|You are about to change this instance runner to a project runner. This operation is not reversible. Are you sure you want to continue?"
msgstr ""
msgid "Runners|You can set up a specific runner to be used by multiple projects but you cannot make this a shared runner."
msgstr ""

View file

@ -311,23 +311,42 @@ RSpec.describe Projects::PipelinesController do
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
def create_build_with_artifacts(stage, stage_idx, name)
create(:ci_build, :artifacts, :tags, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name)
def create_build_with_artifacts(stage, stage_idx, name, status)
create(:ci_build, :artifacts, :tags, status, user: user, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name)
end
def create_bridge(stage, stage_idx, name, status)
create(:ci_bridge, status, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name)
end
before do
create_build_with_artifacts('build', 0, 'job1')
create_build_with_artifacts('build', 0, 'job2')
create_build_with_artifacts('build', 0, 'job1', :failed)
create_build_with_artifacts('build', 0, 'job2', :running)
create_build_with_artifacts('build', 0, 'job3', :pending)
create_bridge('deploy', 1, 'deploy-a', :failed)
create_bridge('deploy', 1, 'deploy-b', :created)
end
it 'avoids N+1 database queries', :request_store do
control_count = ActiveRecord::QueryRecorder.new { get_pipeline_html }.count
it 'avoids N+1 database queries', :request_store, :use_sql_query_cache do
# warm up
get_pipeline_html
expect(response).to have_gitlab_http_status(:ok)
create_build_with_artifacts('build', 0, 'job3')
control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
get_pipeline_html
expect(response).to have_gitlab_http_status(:ok)
end
expect { get_pipeline_html }.not_to exceed_query_limit(control_count)
expect(response).to have_gitlab_http_status(:ok)
create_build_with_artifacts('build', 0, 'job4', :failed)
create_build_with_artifacts('build', 0, 'job5', :running)
create_build_with_artifacts('build', 0, 'job6', :pending)
create_bridge('deploy', 1, 'deploy-c', :failed)
create_bridge('deploy', 1, 'deploy-d', :created)
expect do
get_pipeline_html
expect(response).to have_gitlab_http_status(:ok)
end.not_to exceed_all_query_limit(control)
end
end

View file

@ -283,6 +283,26 @@ RSpec.describe RegistrationsController do
end
end
context 'when the registration fails' do
let_it_be(:member) { create(:project_member, :invited) }
let_it_be(:missing_user_params) do
{ username: '', email: member.invite_email, password: 'Any_password' }
end
let_it_be(:user_params) { { user: missing_user_params } }
let(:session_params) { { invite_email: member.invite_email } }
subject { post(:create, params: user_params, session: session_params) }
it 'does not delete the invitation or register the new user' do
subject
expect(member.invite_token).not_to be_nil
expect(controller.current_user).to be_nil
end
end
context 'when soft email confirmation is enabled' do
before do
stub_feature_flags(soft_email_confirmation: true)

View file

@ -189,6 +189,16 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
end
context 'email confirmation enabled' do
context 'when user is not valid in sign up form' do
let(:new_user) { build_stubbed(:user, first_name: '', last_name: '') }
it 'fails sign up and redirects back to sign up', :aggregate_failures do
expect { fill_in_sign_up_form(new_user) }.not_to change { User.count }
expect(page).to have_content('prohibited this user from being saved')
expect(current_path).to eq(user_registration_path)
end
end
context 'with invite email acceptance', :snowplow do
it 'tracks the accepted invite' do
fill_in_sign_up_form(new_user)

View file

@ -1,3 +1,4 @@
import { GlModal } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue';
@ -29,6 +30,8 @@ describe('Deploy freeze table', () => {
const findAddDeployFreezeButton = () => wrapper.find('[data-testid="add-deploy-freeze"]');
const findEditDeployFreezeButton = () => wrapper.find('[data-testid="edit-deploy-freeze"]');
const findDeployFreezeTable = () => wrapper.find('[data-testid="deploy-freeze-table"]');
const findDeleteDeployFreezeButton = () => wrapper.find('[data-testid="delete-deploy-freeze"]');
const findDeleteDeployFreezeModal = () => wrapper.findComponent(GlModal);
beforeEach(() => {
createComponent();
@ -73,6 +76,29 @@ describe('Deploy freeze table', () => {
store.state.freezePeriods[0],
);
});
it('displays delete deploy freeze button', () => {
expect(findDeleteDeployFreezeButton().exists()).toBe(true);
});
it('confirms a user wants to delete a deploy freeze', async () => {
const [{ freezeStart, freezeEnd, cronTimezone }] = store.state.freezePeriods;
await findDeleteDeployFreezeButton().trigger('click');
const modal = findDeleteDeployFreezeModal();
expect(modal.text()).toContain(
`Deploy freeze from ${freezeStart} to ${freezeEnd} in ${cronTimezone.formattedTimezone} will be removed.`,
);
});
it('deletes the freeze period on confirmation', async () => {
await findDeleteDeployFreezeButton().trigger('click');
const modal = findDeleteDeployFreezeModal();
modal.vm.$emit('primary');
expect(store.dispatch).toHaveBeenCalledWith(
'deleteFreezePeriod',
store.state.freezePeriods[0],
);
});
});
});

View file

@ -12,6 +12,7 @@ jest.mock('~/api.js');
jest.mock('~/flash.js');
describe('deploy freeze store actions', () => {
const freezePeriodFixture = freezePeriodsFixture[0];
let mock;
let state;
@ -24,6 +25,7 @@ describe('deploy freeze store actions', () => {
Api.freezePeriods.mockResolvedValue({ data: freezePeriodsFixture });
Api.createFreezePeriod.mockResolvedValue();
Api.updateFreezePeriod.mockResolvedValue();
Api.deleteFreezePeriod.mockResolvedValue();
});
afterEach(() => {
@ -195,4 +197,46 @@ describe('deploy freeze store actions', () => {
);
});
});
describe('deleteFreezePeriod', () => {
it('dispatch correct actions on deleting a freeze period', () => {
testAction(
actions.deleteFreezePeriod,
freezePeriodFixture,
state,
[
{ type: 'REQUEST_DELETE_FREEZE_PERIOD', payload: freezePeriodFixture.id },
{ type: 'RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS', payload: freezePeriodFixture.id },
],
[],
() =>
expect(Api.deleteFreezePeriod).toHaveBeenCalledWith(
state.projectId,
freezePeriodFixture.id,
),
);
});
it('should show flash error and set error in state on delete failure', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
const error = new Error();
Api.deleteFreezePeriod.mockRejectedValue(error);
testAction(
actions.deleteFreezePeriod,
freezePeriodFixture,
state,
[
{ type: 'REQUEST_DELETE_FREEZE_PERIOD', payload: freezePeriodFixture.id },
{ type: 'RECEIVE_DELETE_FREEZE_PERIOD_ERROR', payload: freezePeriodFixture.id },
],
[],
() => {
expect(createFlash).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith('[gitlab] Unable to delete deploy freeze:', error);
},
);
});
});
});

View file

@ -28,9 +28,9 @@ describe('Deploy freeze mutations', () => {
describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => {
it('should set freeze periods and format timezones from identifiers to names', () => {
const timezoneNames = {
'Europe/Berlin': 'Berlin',
'Etc/UTC': 'UTC',
'America/New_York': 'Eastern Time (US & Canada)',
'Europe/Berlin': '[UTC 2] Berlin',
'Etc/UTC': '[UTC 0] UTC',
'America/New_York': '[UTC -4] Eastern Time (US & Canada)',
};
mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, freezePeriodsFixture);

View file

@ -31,7 +31,6 @@ describe('Environment item', () => {
factory({
propsData: {
model: environment,
canReadEnvironment: true,
tableData,
},
});
@ -135,7 +134,6 @@ describe('Environment item', () => {
factory({
propsData: {
model: environmentWithoutDeployable,
canReadEnvironment: true,
tableData,
},
});
@ -161,7 +159,6 @@ describe('Environment item', () => {
factory({
propsData: {
model: environmentWithoutUpcomingDeployment,
canReadEnvironment: true,
tableData,
},
});
@ -177,7 +174,6 @@ describe('Environment item', () => {
factory({
propsData: {
model: environment,
canReadEnvironment: true,
tableData,
shouldShowAutoStopDate: true,
},
@ -205,7 +201,6 @@ describe('Environment item', () => {
...environment,
auto_stop_at: futureDate,
},
canReadEnvironment: true,
tableData,
shouldShowAutoStopDate: true,
},
@ -241,7 +236,6 @@ describe('Environment item', () => {
...environment,
auto_stop_at: pastDate,
},
canReadEnvironment: true,
tableData,
shouldShowAutoStopDate: true,
},
@ -360,7 +354,6 @@ describe('Environment item', () => {
factory({
propsData: {
model: folder,
canReadEnvironment: true,
tableData,
},
});

View file

@ -28,7 +28,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: [folder],
canReadEnvironment: true,
...eeOnlyProps,
},
});
@ -50,7 +49,6 @@ describe('Environment table', () => {
await factory({
propsData: {
environments: [mockItem],
canReadEnvironment: true,
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
helpCanaryDeploymentsPath: 'help/canary-deployments',
@ -78,7 +76,6 @@ describe('Environment table', () => {
propsData: {
environments: [mockItem],
canCreateDeployment: false,
canReadEnvironment: true,
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
helpCanaryDeploymentsPath: 'help/canary-deployments',
@ -114,7 +111,6 @@ describe('Environment table', () => {
propsData: {
environments: [mockItem],
canCreateDeployment: false,
canReadEnvironment: true,
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
helpCanaryDeploymentsPath: 'help/canary-deployments',
@ -151,7 +147,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: [mockItem],
canReadEnvironment: true,
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
helpCanaryDeploymentsPath: 'help/canary-deployments',
@ -179,7 +174,6 @@ describe('Environment table', () => {
propsData: {
environments: [mockItem],
canCreateDeployment: false,
canReadEnvironment: true,
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
helpCanaryDeploymentsPath: 'help/canary-deployments',
@ -230,7 +224,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: mockItems,
canReadEnvironment: true,
...eeOnlyProps,
},
});
@ -296,7 +289,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: mockItems,
canReadEnvironment: true,
...eeOnlyProps,
},
});
@ -335,7 +327,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: mockItems,
canReadEnvironment: true,
...eeOnlyProps,
},
});
@ -364,7 +355,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: mockItems,
canReadEnvironment: true,
...eeOnlyProps,
},
});
@ -415,7 +405,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: mockItems,
canReadEnvironment: true,
...eeOnlyProps,
},
});

View file

@ -20,7 +20,6 @@ describe('Environment', () => {
const mockData = {
endpoint: 'environments.json',
canCreateEnvironment: true,
canReadEnvironment: true,
newEnvironmentPath: 'environments/new',
helpPagePath: 'help',
userCalloutsPath: '/callouts',

View file

@ -44,7 +44,6 @@ describe('Environments detail header component', () => {
TimeAgo,
},
propsData: {
canReadEnvironment: false,
canAdminEnvironment: false,
canUpdateEnvironment: false,
canStopEnvironment: false,
@ -60,7 +59,7 @@ describe('Environments detail header component', () => {
describe('default state with minimal access', () => {
beforeEach(() => {
createWrapper({ props: { environment: createEnvironment() } });
createWrapper({ props: { environment: createEnvironment({ externalUrl: null }) } });
});
it('displays the environment name', () => {
@ -164,7 +163,6 @@ describe('Environments detail header component', () => {
createWrapper({
props: {
environment: createEnvironment({ hasTerminals: true, externalUrl }),
canReadEnvironment: true,
},
});
});
@ -178,8 +176,7 @@ describe('Environments detail header component', () => {
beforeEach(() => {
createWrapper({
props: {
environment: createEnvironment(),
canReadEnvironment: true,
environment: createEnvironment({ metricsUrl: 'my metrics url' }),
metricsPath,
},
});
@ -195,7 +192,6 @@ describe('Environments detail header component', () => {
createWrapper({
props: {
environment: createEnvironment(),
canReadEnvironment: true,
canAdminEnvironment: true,
canStopEnvironment: true,
canUpdateEnvironment: true,

View file

@ -11,7 +11,6 @@ describe('Environments Folder View', () => {
const mockData = {
endpoint: 'environments.json',
folderName: 'review',
canReadEnvironment: true,
cssContainerClass: 'container',
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',

View file

@ -14,7 +14,6 @@ describe('Environments Folder View', () => {
const mockData = {
endpoint: 'environments.json',
folderName: 'review',
canReadEnvironment: true,
cssContainerClass: 'container',
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',

View file

@ -0,0 +1,25 @@
import { shallowMount } from '@vue/test-utils';
import ImageViewer from '~/repository/components/blob_viewers/image_viewer.vue';
describe('Image Viewer', () => {
let wrapper;
const propsData = {
url: 'some/image.png',
alt: 'image.png',
};
const createComponent = () => {
wrapper = shallowMount(ImageViewer, { propsData });
};
const findImage = () => wrapper.find('[data-testid="image"]');
it('renders a Source Editor component', () => {
createComponent();
expect(findImage().exists()).toBe(true);
expect(findImage().attributes('src')).toBe(propsData.url);
expect(findImage().attributes('alt')).toBe(propsData.alt);
});
});

View file

@ -9,6 +9,6 @@ describe('search/highlight_blob_search_result', () => {
it('highlights lines with search term occurrence', () => {
setHighlightClass(searchKeyword);
expect(document.querySelectorAll('.blob-result .hll').length).toBe(4);
expect(document.querySelectorAll('.js-blob-result .hll').length).toBe(4);
});
});

View file

@ -20,18 +20,20 @@ RSpec.describe Resolvers::BoardListIssuesResolver do
let!(:issue1) { create(:issue, project: project, labels: [label], relative_position: 10) }
let!(:issue2) { create(:issue, project: project, labels: [label, label2], relative_position: 12) }
let!(:issue3) { create(:issue, project: project, labels: [label, label3], relative_position: 10) }
let!(:issue4) { create(:issue, project: project, labels: [label], relative_position: nil) }
it 'returns the issues in the correct order' do
it 'returns issues in the correct order with non-nil relative positions', :aggregate_failures do
# by relative_position and then ID
issues = resolve_board_list_issues
result = resolve_board_list_issues
expect(issues.map(&:id)).to eq [issue3.id, issue1.id, issue2.id]
expect(result.map(&:id)).to eq [issue3.id, issue1.id, issue2.id, issue4.id]
expect(result.map(&:relative_position)).not_to include(nil)
end
it 'finds only issues matching filters' do
result = resolve_board_list_issues(args: { filters: { label_name: [label.title], not: { label_name: [label2.title] } } })
expect(result).to match_array([issue1, issue3])
expect(result).to match_array([issue1, issue3, issue4])
end
it 'finds only issues matching search param' do
@ -49,7 +51,7 @@ RSpec.describe Resolvers::BoardListIssuesResolver do
it 'accepts assignee wildcard id NONE' do
result = resolve_board_list_issues(args: { filters: { assignee_wildcard_id: 'NONE' } })
expect(result).to match_array([issue1, issue2, issue3])
expect(result).to match_array([issue1, issue2, issue3, issue4])
end
it 'accepts assignee wildcard id ANY' do
@ -89,6 +91,6 @@ RSpec.describe Resolvers::BoardListIssuesResolver do
end
def resolve_board_list_issues(args: {}, current_user: user)
resolve(described_class, obj: list, args: args, ctx: { current_user: current_user })
resolve(described_class, obj: list, args: args, ctx: { current_user: current_user }).items
end
end

View file

@ -43,7 +43,6 @@ RSpec.describe EnvironmentHelper do
external_url: environment.external_url,
can_update_environment: true,
can_destroy_environment: true,
can_read_environment: true,
can_stop_environment: true,
can_admin_environment: true,
environment_metrics_path: environment_metrics_path(environment),

View file

@ -297,4 +297,65 @@ RSpec.describe Gitlab::Ci::CronParser do
it { is_expected.to eq(true) }
end
end
describe '.parse_natural', :aggregate_failures do
let(:cron_line) { described_class.parse_natural_with_timestamp(time, { unit: 'day', duration: 1 }) }
let(:time) { Time.parse('Mon, 30 Aug 2021 06:29:44.067132000 UTC +00:00') }
let(:hours) { Fugit::Cron.parse(cron_line).hours }
let(:minutes) { Fugit::Cron.parse(cron_line).minutes }
let(:weekdays) { Fugit::Cron.parse(cron_line).weekdays.first }
let(:months) { Fugit::Cron.parse(cron_line).months }
context 'when repeat cycle is day' do
it 'generates daily cron expression', :aggregate_failures do
expect(hours).to include time.hour
expect(minutes).to include time.min
end
end
context 'when repeat cycle is week' do
let(:cron_line) { described_class.parse_natural_with_timestamp(time, { unit: 'week', duration: 1 }) }
it 'generates weekly cron expression', :aggregate_failures do
expect(hours).to include time.hour
expect(minutes).to include time.min
expect(weekdays).to include time.wday
end
end
context 'when repeat cycle is month' do
let(:cron_line) { described_class.parse_natural_with_timestamp(time, { unit: 'month', duration: 3 }) }
it 'generates monthly cron expression', :aggregate_failures do
expect(minutes).to include time.min
expect(months).to include time.month
end
context 'when an unsupported duration is specified' do
subject { described_class.parse_natural_with_timestamp(time, { unit: 'month', duration: 7 }) }
it 'raises an exception' do
expect { subject }.to raise_error(NotImplementedError, 'The cadence {:unit=>"month", :duration=>7} is not supported')
end
end
end
context 'when repeat cycle is year' do
let(:cron_line) { described_class.parse_natural_with_timestamp(time, { unit: 'year', duration: 1 }) }
it 'generates yearly cron expression', :aggregate_failures do
expect(hours).to include time.hour
expect(minutes).to include time.min
expect(months).to include time.month
end
end
context 'when the repeat cycle is not implemented' do
subject { described_class.parse_natural_with_timestamp(time, { unit: 'quarterly', duration: 1 }) }
it 'raises an exception' do
expect { subject }.to raise_error(NotImplementedError, 'The cadence unit quarterly is not implemented')
end
end
end
end

View file

@ -224,6 +224,7 @@ ci_pipelines:
- builds
- bridges
- processables
- generic_commit_statuses
- trigger_requests
- variables
- auto_canceled_by

View file

@ -645,6 +645,16 @@ RSpec.describe Member do
expect(user.authorized_projects.reload).to include(project)
end
it 'does not accept the invite if saving a new user fails' do
invalid_user = User.new(first_name: '', last_name: '')
member.accept_invite! invalid_user
expect(member.invite_accepted_at).to be_nil
expect(member.invite_token).not_to be_nil
expect_any_instance_of(Member).not_to receive(:after_accept_invite)
end
end
describe "#decline_invite!" do

View file

@ -1576,6 +1576,14 @@ RSpec.describe Note do
expect(note.post_processed_cache_key).to eq("#{note.cache_key}:#{note.author.cache_key}")
end
context 'when note has no author' do
let(:note) { build(:note, author: nil) }
it 'returns cache key only' do
expect(note.post_processed_cache_key).to eq("#{note.cache_key}:")
end
end
context 'when note has redacted_note_html' do
let(:redacted_note_html) { 'redacted note html' }

View file

@ -48,13 +48,18 @@ RSpec.describe 'get board lists' do
issues_data.map { |i| i['title'] }
end
def issue_relative_positions
issues_data.map { |i| i['relativePosition'] }
end
shared_examples 'group and project board list issues query' do
let!(:board) { create(:board, resource_parent: board_parent) }
let!(:label_list) { create(:list, board: board, label: label, position: 10) }
let!(:issue1) { create(:issue, project: issue_project, labels: [label, label2], relative_position: 9) }
let!(:issue2) { create(:issue, project: issue_project, labels: [label, label2], relative_position: 2) }
let!(:issue3) { create(:issue, project: issue_project, labels: [label], relative_position: 9) }
let!(:issue4) { create(:issue, project: issue_project, labels: [label2], relative_position: 432) }
let!(:issue3) { create(:issue, project: issue_project, labels: [label, label2], relative_position: nil) }
let!(:issue4) { create(:issue, project: issue_project, labels: [label], relative_position: 9) }
let!(:issue5) { create(:issue, project: issue_project, labels: [label2], relative_position: 432) }
context 'when the user does not have access to the board' do
it 'returns nil' do
@ -69,10 +74,11 @@ RSpec.describe 'get board lists' do
board_parent.add_reporter(user)
end
it 'can access the issues' do
it 'can access the issues', :aggregate_failures do
post_graphql(query("id: \"#{global_id_of(label_list)}\""), current_user: user)
expect(issue_titles).to eq([issue2.title, issue1.title])
expect(issue_titles).to eq([issue2.title, issue1.title, issue3.title])
expect(issue_relative_positions).not_to include(nil)
end
end
end

View file

@ -311,6 +311,10 @@ RSpec.describe 'getting pipeline information nested in a project' do
end
it 'does not generate N+1 queries', :request_store, :use_sql_query_cache do
# create extra statuses
create(:generic_commit_status, :pending, name: 'generic-build-a', pipeline: pipeline, stage_idx: 0, stage: 'build')
create(:ci_bridge, :failed, name: 'deploy-a', pipeline: pipeline, stage_idx: 2, stage: 'deploy')
# warm up
post_graphql(query, current_user: current_user)
@ -318,9 +322,11 @@ RSpec.describe 'getting pipeline information nested in a project' do
post_graphql(query, current_user: current_user)
end
create(:ci_build, name: 'test-a', pipeline: pipeline, stage_idx: 1, stage: 'test')
create(:ci_build, name: 'test-b', pipeline: pipeline, stage_idx: 1, stage: 'test')
create(:ci_build, name: 'deploy-a', pipeline: pipeline, stage_idx: 2, stage: 'deploy')
create(:generic_commit_status, :pending, name: 'generic-build-b', pipeline: pipeline, stage_idx: 0, stage: 'build')
create(:ci_build, :failed, name: 'test-a', pipeline: pipeline, stage_idx: 1, stage: 'test')
create(:ci_build, :running, name: 'test-b', pipeline: pipeline, stage_idx: 1, stage: 'test')
create(:ci_build, :pending, name: 'deploy-b', pipeline: pipeline, stage_idx: 2, stage: 'deploy')
create(:ci_bridge, :failed, name: 'deploy-c', pipeline: pipeline, stage_idx: 2, stage: 'deploy')
expect do
post_graphql(query, current_user: current_user)

View file

@ -139,4 +139,51 @@ RSpec.describe Boards::Issues::ListService do
end
# rubocop: enable RSpec/MultipleMemoizedHelpers
end
describe '.initialize_relative_positions' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:board) { create(:board, project: project) }
let_it_be(:backlog) { create(:backlog_list, board: board) }
let(:issue) { create(:issue, project: project, relative_position: nil) }
context "when 'Gitlab::Database::read_write?' is true" do
before do
allow(Gitlab::Database).to receive(:read_write?).and_return(true)
end
context 'user cannot move issues' do
it 'does not initialize the relative positions of issues' do
described_class.initialize_relative_positions(board, user, [issue])
expect(issue.relative_position).to eq nil
end
end
context 'user can move issues' do
before do
project.add_developer(user)
end
it 'initializes the relative positions of issues' do
described_class.initialize_relative_positions(board, user, [issue])
expect(issue.relative_position).not_to eq nil
end
end
end
context "when 'Gitlab::Database::read_write?' is false" do
before do
allow(Gitlab::Database).to receive(:read_write?).and_return(false)
end
it 'does not initialize the relative positions of issues' do
described_class.initialize_relative_positions(board, user, [issue])
expect(issue.relative_position).to eq nil
end
end
end
end