Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-01-03 09:07:33 +00:00
parent 2cfa1fc75d
commit c0d8f9f3f9
65 changed files with 1716 additions and 347 deletions

View file

@ -1,22 +1,23 @@
<script> <script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ /* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { format } from 'timeago.js';
import _ from 'underscore'; import _ from 'underscore';
import { GlTooltipDirective } from '@gitlab/ui'; import { GlTooltipDirective } from '@gitlab/ui';
import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin'; import { __, sprintf } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import { __, sprintf } from '~/locale'; import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin';
import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue'; import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue'; import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue';
import RollbackComponent from './environment_rollback.vue';
import TerminalButtonComponent from './environment_terminal_button.vue';
import MonitoringButtonComponent from './environment_monitoring.vue'; import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue'; import PinComponent from './environment_pin.vue';
import eventHub from '../event_hub'; import RollbackComponent from './environment_rollback.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import StopComponent from './environment_stop.vue';
import TerminalButtonComponent from './environment_terminal_button.vue';
/** /**
* Environment Item Component * Environment Item Component
@ -26,21 +27,22 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default { export default {
components: { components: {
CommitComponent,
Icon,
ActionsComponent, ActionsComponent,
CommitComponent,
ExternalUrlComponent, ExternalUrlComponent,
StopComponent, Icon,
RollbackComponent,
TerminalButtonComponent,
MonitoringButtonComponent, MonitoringButtonComponent,
PinComponent,
RollbackComponent,
StopComponent,
TerminalButtonComponent,
TooltipOnTruncate, TooltipOnTruncate,
UserAvatarLink, UserAvatarLink,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
mixins: [environmentItemMixin], mixins: [environmentItemMixin, timeagoMixin],
props: { props: {
canReadEnvironment: { canReadEnvironment: {
@ -52,7 +54,12 @@ export default {
model: { model: {
type: Object, type: Object,
required: true, required: true,
default: () => ({}), },
shouldShowAutoStopDate: {
type: Boolean,
required: false,
default: false,
}, },
tableData: { tableData: {
@ -76,6 +83,16 @@ export default {
return false; return false;
}, },
/**
* Checkes whether the row displayed is a folder.
*
* @returns {Boolean}
*/
isFolder() {
return this.model.isFolder;
},
/** /**
* Checkes whether the environment is protected. * Checkes whether the environment is protected.
* (`is_protected` currently only set in EE) * (`is_protected` currently only set in EE)
@ -112,24 +129,64 @@ export default {
}, },
/** /**
* Verifies if the date to be shown is present. * Verifies if the autostop date is present.
*
* @returns {Boolean}
*/
canShowAutoStopDate() {
if (!this.model.auto_stop_at) {
return false;
}
const autoStopDate = new Date(this.model.auto_stop_at);
const now = new Date();
return now < autoStopDate;
},
/**
* Human readable deployment date.
*
* @returns {String}
*/
autoStopDate() {
if (this.canShowAutoStopDate) {
return {
formatted: this.timeFormatted(this.model.auto_stop_at),
tooltip: this.tooltipTitle(this.model.auto_stop_at),
};
}
return {
formatted: '',
tooltip: '',
};
},
/**
* Verifies if the deployment date is present.
* *
* @returns {Boolean|Undefined} * @returns {Boolean|Undefined}
*/ */
canShowDate() { canShowDeploymentDate() {
return this.model && this.model.last_deployment && this.model.last_deployment.deployed_at; return this.model && this.model.last_deployment && this.model.last_deployment.deployed_at;
}, },
/** /**
* Human readable date. * Human readable deployment date.
* *
* @returns {String} * @returns {String}
*/ */
deployedDate() { deployedDate() {
if (this.canShowDate) { if (this.canShowDeploymentDate) {
return format(this.model.last_deployment.deployed_at); return {
formatted: this.timeFormatted(this.model.last_deployment.deployed_at),
tooltip: this.tooltipTitle(this.model.last_deployment.deployed_at),
};
} }
return ''; return {
formatted: '',
tooltip: '',
};
}, },
actions() { actions() {
@ -344,6 +401,15 @@ export default {
return {}; return {};
}, },
/**
* Checkes whether to display no deployment text.
*
* @returns {Boolean}
*/
showNoDeployments() {
return !this.hasLastDeploymentKey && !this.isFolder;
},
/** /**
* Verifies if the build name column should be rendered by verifing * Verifies if the build name column should be rendered by verifing
* if all the information needed is present * if all the information needed is present
@ -353,7 +419,7 @@ export default {
*/ */
shouldRenderBuildName() { shouldRenderBuildName() {
return ( return (
!this.model.isFolder && !this.isFolder &&
!_.isEmpty(this.model.last_deployment) && !_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.deployable) !_.isEmpty(this.model.last_deployment.deployable)
); );
@ -383,11 +449,7 @@ export default {
* @return {String} * @return {String}
*/ */
externalURL() { externalURL() {
if (this.model && this.model.external_url) { return this.model.external_url || '';
return this.model.external_url;
}
return '';
}, },
/** /**
@ -399,26 +461,22 @@ export default {
*/ */
shouldRenderDeploymentID() { shouldRenderDeploymentID() {
return ( return (
!this.model.isFolder && !this.isFolder &&
!_.isEmpty(this.model.last_deployment) && !_.isEmpty(this.model.last_deployment) &&
this.model.last_deployment.iid !== undefined this.model.last_deployment.iid !== undefined
); );
}, },
environmentPath() { environmentPath() {
if (this.model && this.model.environment_path) { return this.model.environment_path || '';
return this.model.environment_path;
}
return '';
}, },
monitoringUrl() { monitoringUrl() {
if (this.model && this.model.metrics_path) { return this.model.metrics_path || '';
return this.model.metrics_path; },
}
return ''; autoStopUrl() {
return this.model.cancel_auto_stop_path || '';
}, },
displayEnvironmentActions() { displayEnvironmentActions() {
@ -447,7 +505,7 @@ export default {
<div <div
:class="{ :class="{
'js-child-row environment-child-row': model.isChildren, 'js-child-row environment-child-row': model.isChildren,
'folder-row': model.isFolder, 'folder-row': isFolder,
}" }"
class="gl-responsive-table-row" class="gl-responsive-table-row"
role="row" role="row"
@ -457,7 +515,7 @@ export default {
:class="tableData.name.spacing" :class="tableData.name.spacing"
role="gridcell" role="gridcell"
> >
<div v-if="!model.isFolder" class="table-mobile-header" role="rowheader"> <div v-if="!isFolder" class="table-mobile-header" role="rowheader">
{{ tableData.name.title }} {{ tableData.name.title }}
</div> </div>
@ -466,7 +524,7 @@ export default {
</span> </span>
<span <span
v-if="!model.isFolder" v-if="!isFolder"
v-gl-tooltip v-gl-tooltip
:title="model.name" :title="model.name"
class="environment-name table-mobile-content" class="environment-name table-mobile-content"
@ -506,7 +564,7 @@ export default {
{{ deploymentInternalId }} {{ deploymentInternalId }}
</span> </span>
<span v-if="!model.isFolder && deploymentHasUser" class="text-break-word"> <span v-if="!isFolder && deploymentHasUser" class="text-break-word">
by by
<user-avatar-link <user-avatar-link
:link-href="deploymentUser.web_url" :link-href="deploymentUser.web_url"
@ -516,6 +574,10 @@ export default {
class="js-deploy-user-container float-none" class="js-deploy-user-container float-none"
/> />
</span> </span>
<div v-if="showNoDeployments" class="commit-title table-mobile-content">
{{ s__('Environments|No deployments yet') }}
</div>
</div> </div>
<div <div
@ -536,14 +598,8 @@ export default {
</a> </a>
</div> </div>
<div <div v-if="!isFolder" class="table-section" :class="tableData.commit.spacing" role="gridcell">
v-if="!model.isFolder"
class="table-section"
:class="tableData.commit.spacing"
role="gridcell"
>
<div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div> <div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div>
<div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content"> <div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content">
<commit-component <commit-component
:tag="commitTag" :tag="commitTag"
@ -554,31 +610,51 @@ export default {
:author="commitAuthor" :author="commitAuthor"
/> />
</div> </div>
<div v-if="!hasLastDeploymentKey" class="commit-title table-mobile-content">
{{ s__('Environments|No deployments yet') }}
</div>
</div> </div>
<div <div v-if="!isFolder" class="table-section" :class="tableData.date.spacing" role="gridcell">
v-if="!model.isFolder"
class="table-section"
:class="tableData.date.spacing"
role="gridcell"
>
<div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div> <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div>
<span
<span v-if="canShowDate" class="environment-created-date-timeago table-mobile-content"> v-if="canShowDeploymentDate"
{{ deployedDate }} v-gl-tooltip
:title="deployedDate.tooltip"
class="environment-created-date-timeago table-mobile-content flex-truncate-parent"
>
<span class="flex-truncate-child">
{{ deployedDate.formatted }}
</span>
</span> </span>
</div> </div>
<div <div
v-if="!model.isFolder && displayEnvironmentActions" v-if="!isFolder && shouldShowAutoStopDate"
class="table-section"
:class="tableData.autoStop.spacing"
role="gridcell"
>
<div role="rowheader" class="table-mobile-header">{{ tableData.autoStop.title }}</div>
<span
v-if="canShowAutoStopDate"
v-gl-tooltip
:title="autoStopDate.tooltip"
class="table-mobile-content flex-truncate-parent"
>
<span class="flex-truncate-child js-auto-stop">{{ autoStopDate.formatted }}</span>
</span>
</div>
<div
v-if="!isFolder && displayEnvironmentActions"
class="table-section table-button-footer" class="table-section table-button-footer"
:class="tableData.actions.spacing" :class="tableData.actions.spacing"
role="gridcell" role="gridcell"
> >
<div class="btn-group table-action-buttons" role="group"> <div class="btn-group table-action-buttons" role="group">
<pin-component
v-if="canShowAutoStopDate && shouldShowAutoStopDate"
:auto-stop-url="autoStopUrl"
/>
<external-url-component <external-url-component
v-if="externalURL && canReadEnvironment" v-if="externalURL && canReadEnvironment"
:external-url="externalURL" :external-url="externalURL"

View file

@ -0,0 +1,37 @@
<script>
/**
* Renders a prevent auto-stop button.
* Used in environments table.
*/
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { __ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
Icon,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
autoStopUrl: {
type: String,
required: true,
},
},
methods: {
onPinClick() {
eventHub.$emit('cancelAutoStop', this.autoStopUrl);
},
},
title: __('Prevent environment from auto-stopping'),
};
</script>
<template>
<gl-button v-gl-tooltip :title="$options.title" :aria-label="$options.title" @click="onPinClick">
<icon name="thumbtack" />
</gl-button>
</template>

View file

@ -6,6 +6,7 @@ import { GlLoadingIcon } from '@gitlab/ui';
import _ from 'underscore'; import _ from 'underscore';
import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin'; import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import EnvironmentItem from './environment_item.vue'; import EnvironmentItem from './environment_item.vue';
export default { export default {
@ -16,7 +17,7 @@ export default {
CanaryDeploymentCallout: () => CanaryDeploymentCallout: () =>
import('ee_component/environments/components/canary_deployment_callout.vue'), import('ee_component/environments/components/canary_deployment_callout.vue'),
}, },
mixins: [environmentTableMixin], mixins: [environmentTableMixin, glFeatureFlagsMixin()],
props: { props: {
environments: { environments: {
type: Array, type: Array,
@ -42,6 +43,9 @@ export default {
: env, : env,
); );
}, },
shouldShowAutoStopDate() {
return this.glFeatures.autoStopEnvironments;
},
tableData() { tableData() {
return { return {
// percent spacing for cols, should add up to 100 // percent spacing for cols, should add up to 100
@ -65,8 +69,12 @@ export default {
title: s__('Environments|Updated'), title: s__('Environments|Updated'),
spacing: 'section-10', spacing: 'section-10',
}, },
autoStop: {
title: s__('Environments|Auto stop in'),
spacing: 'section-5',
},
actions: { actions: {
spacing: 'section-30', spacing: this.shouldShowAutoStopDate ? 'section-25' : 'section-30',
}, },
}; };
}, },
@ -123,6 +131,14 @@ export default {
<div class="table-section" :class="tableData.date.spacing" role="columnheader"> <div class="table-section" :class="tableData.date.spacing" role="columnheader">
{{ tableData.date.title }} {{ tableData.date.title }}
</div> </div>
<div
v-if="shouldShowAutoStopDate"
class="table-section"
:class="tableData.autoStop.spacing"
role="columnheader"
>
{{ tableData.autoStop.title }}
</div>
</div> </div>
<template v-for="(model, i) in sortedEnvironments" :model="model"> <template v-for="(model, i) in sortedEnvironments" :model="model">
<div <div
@ -130,6 +146,7 @@ export default {
:key="`environment-item-${i}`" :key="`environment-item-${i}`"
:model="model" :model="model"
:can-read-environment="canReadEnvironment" :can-read-environment="canReadEnvironment"
:should-show-auto-stop-date="shouldShowAutoStopDate"
:table-data="tableData" :table-data="tableData"
/> />

View file

@ -90,16 +90,19 @@ export default {
Flash(s__('Environments|An error occurred while fetching the environments.')); Flash(s__('Environments|An error occurred while fetching the environments.'));
}, },
postAction({ endpoint, errorMessage }) { postAction({
endpoint,
errorMessage = s__('Environments|An error occurred while making the request.'),
}) {
if (!this.isMakingRequest) { if (!this.isMakingRequest) {
this.isLoading = true; this.isLoading = true;
this.service this.service
.postAction(endpoint) .postAction(endpoint)
.then(() => this.fetchEnvironments()) .then(() => this.fetchEnvironments())
.catch(() => { .catch(err => {
this.isLoading = false; this.isLoading = false;
Flash(errorMessage || s__('Environments|An error occurred while making the request.')); Flash(_.isFunction(errorMessage) ? errorMessage(err.response.data) : errorMessage);
}); });
} }
}, },
@ -138,6 +141,13 @@ export default {
); );
this.postAction({ endpoint: retryUrl, errorMessage }); this.postAction({ endpoint: retryUrl, errorMessage });
}, },
cancelAutoStop(autoStopPath) {
const errorMessage = ({ message }) =>
message ||
s__('Environments|An error occurred while canceling the auto stop, please try again');
this.postAction({ endpoint: autoStopPath, errorMessage });
},
}, },
computed: { computed: {
@ -199,6 +209,8 @@ export default {
eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal); eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal);
eventHub.$on('rollbackEnvironment', this.rollbackEnvironment); eventHub.$on('rollbackEnvironment', this.rollbackEnvironment);
eventHub.$on('cancelAutoStop', this.cancelAutoStop);
}, },
beforeDestroy() { beforeDestroy() {
@ -208,5 +220,7 @@ export default {
eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal); eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal);
eventHub.$off('rollbackEnvironment', this.rollbackEnvironment); eventHub.$off('rollbackEnvironment', this.rollbackEnvironment);
eventHub.$off('cancelAutoStop', this.cancelAutoStop);
}, },
}; };

View file

@ -4,4 +4,13 @@ class Profiles::ActiveSessionsController < Profiles::ApplicationController
def index def index
@sessions = ActiveSession.list(current_user).reject(&:is_impersonated) @sessions = ActiveSession.list(current_user).reject(&:is_impersonated)
end end
def destroy
ActiveSession.destroy_with_public_id(current_user, params[:id])
respond_to do |format|
format.html { redirect_to profile_active_sessions_url, status: :found }
format.js { head :ok }
end
end
end end

View file

@ -15,6 +15,9 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do
push_frontend_feature_flag(:prometheus_computed_alerts) push_frontend_feature_flag(:prometheus_computed_alerts)
end end
before_action do
push_frontend_feature_flag(:auto_stop_environments)
end
after_action :expire_etag_cache, only: [:cancel_auto_stop] after_action :expire_etag_cache, only: [:cancel_auto_stop]
def index def index

View file

@ -17,7 +17,7 @@ class PipelinesFinder
return Ci::Pipeline.none return Ci::Pipeline.none
end end
items = pipelines items = pipelines.no_child
items = by_scope(items) items = by_scope(items)
items = by_status(items) items = by_status(items)
items = by_ref(items) items = by_ref(items)

View file

@ -6,9 +6,11 @@ class ActiveSession
SESSION_BATCH_SIZE = 200 SESSION_BATCH_SIZE = 200
ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100 ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100
attr_writer :session_id
attr_accessor :created_at, :updated_at, attr_accessor :created_at, :updated_at,
:session_id, :ip_address, :ip_address, :browser, :os,
:browser, :os, :device_name, :device_type, :device_name, :device_type,
:is_impersonated :is_impersonated
def current?(session) def current?(session)
@ -21,6 +23,11 @@ class ActiveSession
device_type&.titleize device_type&.titleize
end end
def public_id
encrypted_id = Gitlab::CryptoHelper.aes256_gcm_encrypt(session_id)
CGI.escape(encrypted_id)
end
def self.set(user, request) def self.set(user, request)
Gitlab::Redis::SharedState.with do |redis| Gitlab::Redis::SharedState.with do |redis|
session_id = request.session.id session_id = request.session.id
@ -70,6 +77,11 @@ class ActiveSession
end end
end end
def self.destroy_with_public_id(user, public_id)
session_id = decrypt_public_id(public_id)
destroy(user, session_id) unless session_id.nil?
end
def self.destroy_sessions(redis, user, session_ids) def self.destroy_sessions(redis, user, session_ids)
key_names = session_ids.map {|session_id| key_name(user.id, session_id) } key_names = session_ids.map {|session_id| key_name(user.id, session_id) }
session_names = session_ids.map {|session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" } session_names = session_ids.map {|session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" }
@ -146,9 +158,9 @@ class ActiveSession
# remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS. # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS.
sessions = active_session_entries(session_ids, user.id, redis) sessions = active_session_entries(session_ids, user.id, redis)
sessions.sort_by! {|session| session.updated_at }.reverse! sessions.sort_by! {|session| session.updated_at }.reverse!
sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS) destroyable_sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS)
sessions = sessions.map { |session| session.session_id } destroyable_session_ids = destroyable_sessions.map { |session| session.send :session_id } # rubocop:disable GitlabSecurity/PublicSend
destroy_sessions(redis, user, sessions) if sessions.any? destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any?
end end
def self.cleaned_up_lookup_entries(redis, user) def self.cleaned_up_lookup_entries(redis, user)
@ -167,4 +179,15 @@ class ActiveSession
entries.compact entries.compact
end end
private_class_method def self.decrypt_public_id(public_id)
decoded_id = CGI.unescape(public_id)
Gitlab::CryptoHelper.aes256_gcm_decrypt(decoded_id)
rescue
nil
end
private
attr_reader :session_id
end end

View file

@ -54,6 +54,10 @@ module Ci
def to_partial_path def to_partial_path
'projects/generic_commit_statuses/generic_commit_status' 'projects/generic_commit_statuses/generic_commit_status'
end end
def yaml_for_downstream
nil
end
end end
end end

View file

@ -61,7 +61,9 @@ module Ci
has_one :chat_data, class_name: 'Ci::PipelineChatData' has_one :chat_data, class_name: 'Ci::PipelineChatData'
has_many :triggered_pipelines, through: :sourced_pipelines, source: :pipeline has_many :triggered_pipelines, through: :sourced_pipelines, source: :pipeline
has_many :child_pipelines, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :sourced_pipelines, source: :pipeline
has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline
has_one :parent_pipeline, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :source_pipeline, source: :source_pipeline
has_one :source_job, through: :source_pipeline, source: :source_job has_one :source_job, through: :source_pipeline, source: :source_job
has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline
@ -213,6 +215,7 @@ module Ci
end end
scope :internal, -> { where(source: internal_sources) } scope :internal, -> { where(source: internal_sources) }
scope :no_child, -> { where.not(source: :parent_pipeline) }
scope :ci_sources, -> { where(config_source: ::Ci::PipelineEnums.ci_config_sources_values) } scope :ci_sources, -> { where(config_source: ::Ci::PipelineEnums.ci_config_sources_values) }
scope :for_user, -> (user) { where(user: user) } scope :for_user, -> (user) { where(user: user) }
scope :for_sha, -> (sha) { where(sha: sha) } scope :for_sha, -> (sha) { where(sha: sha) }
@ -508,10 +511,6 @@ module Ci
builds.skipped.after_stage(stage_idx).find_each(&:process) builds.skipped.after_stage(stage_idx).find_each(&:process)
end end
def child?
false
end
def latest? def latest?
return false unless git_ref && commit.present? return false unless git_ref && commit.present?
@ -694,6 +693,24 @@ module Ci
all_merge_requests.order(id: :desc) all_merge_requests.order(id: :desc)
end end
# If pipeline is a child of another pipeline, include the parent
# and the siblings, otherwise return only itself.
def same_family_pipeline_ids
if (parent = parent_pipeline)
[parent.id] + parent.child_pipelines.pluck(:id)
else
[self.id]
end
end
def child?
parent_pipeline.present?
end
def parent?
child_pipelines.exists?
end
def detailed_status(current_user) def detailed_status(current_user)
Gitlab::Ci::Status::Pipeline::Factory Gitlab::Ci::Status::Pipeline::Factory
.new(self, current_user) .new(self, current_user)

View file

@ -23,10 +23,11 @@ module Ci
schedule: 4, schedule: 4,
api: 5, api: 5,
external: 6, external: 6,
pipeline: 7, cross_project_pipeline: 7,
chat: 8, chat: 8,
merge_request_event: 10, merge_request_event: 10,
external_pull_request_event: 11 external_pull_request_event: 11,
parent_pipeline: 12
} }
end end
@ -38,7 +39,8 @@ module Ci
repository_source: 1, repository_source: 1,
auto_devops_source: 2, auto_devops_source: 2,
remote_source: 4, remote_source: 4,
external_project_source: 5 external_project_source: 5,
bridge_source: 6
} }
end end

View file

@ -18,6 +18,8 @@ module Ci
validates :source_project, presence: true validates :source_project, presence: true
validates :source_job, presence: true validates :source_job, presence: true
validates :source_pipeline, presence: true validates :source_pipeline, presence: true
scope :same_project, -> { where(arel_table[:source_project_id].eq(arel_table[:project_id])) }
end end
end end
end end

View file

@ -13,6 +13,7 @@ module Issuable
include CacheMarkdownField include CacheMarkdownField
include Participable include Participable
include Mentionable include Mentionable
include Milestoneable
include Subscribable include Subscribable
include StripAttribute include StripAttribute
include Awardable include Awardable
@ -56,7 +57,6 @@ module Issuable
belongs_to :author, class_name: 'User' belongs_to :author, class_name: 'User'
belongs_to :updated_by, class_name: 'User' belongs_to :updated_by, class_name: 'User'
belongs_to :last_edited_by, class_name: 'User' belongs_to :last_edited_by, class_name: 'User'
belongs_to :milestone
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent
def authors_loaded? def authors_loaded?
@ -89,18 +89,12 @@ module Issuable
# to avoid breaking the existing Issuables which may have their descriptions longer # to avoid breaking the existing Issuables which may have their descriptions longer
validates :description, length: { maximum: DESCRIPTION_LENGTH_MAX }, allow_blank: true, on: :create validates :description, length: { maximum: DESCRIPTION_LENGTH_MAX }, allow_blank: true, on: :create
validate :description_max_length_for_new_records_is_valid, on: :update validate :description_max_length_for_new_records_is_valid, on: :update
validate :milestone_is_valid
before_validation :truncate_description_on_import! before_validation :truncate_description_on_import!
scope :authored, ->(user) { where(author_id: user) } scope :authored, ->(user) { where(author_id: user) }
scope :recent, -> { reorder(id: :desc) } scope :recent, -> { reorder(id: :desc) }
scope :of_projects, ->(ids) { where(project_id: ids) } scope :of_projects, ->(ids) { where(project_id: ids) }
scope :of_milestones, ->(ids) { where(milestone_id: ids) }
scope :any_milestone, -> { where('milestone_id IS NOT NULL') }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
scope :any_release, -> { joins_milestone_releases }
scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) }
scope :opened, -> { with_state(:opened) } scope :opened, -> { with_state(:opened) }
scope :only_opened, -> { with_state(:opened) } scope :only_opened, -> { with_state(:opened) }
scope :closed, -> { with_state(:closed) } scope :closed, -> { with_state(:closed) }
@ -118,20 +112,6 @@ module Issuable
end end
# rubocop:enable GitlabSecurity/SqlInjection # rubocop:enable GitlabSecurity/SqlInjection
scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) }
scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) }
scope :without_release, -> do
joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id")
.where('milestone_releases.release_id IS NULL')
end
scope :joins_milestone_releases, -> do
joins("JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id
JOIN releases ON milestone_releases.release_id = releases.id").distinct
end
scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) } scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
scope :any_label, -> { joins(:label_links).group(:id) } scope :any_label, -> { joins(:label_links).group(:id) }
scope :join_project, -> { joins(:project) } scope :join_project, -> { joins(:project) }
@ -164,10 +144,6 @@ module Issuable
private private
def milestone_is_valid
errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available?
end
def description_max_length_for_new_records_is_valid def description_max_length_for_new_records_is_valid
if new_record? && description.length > Issuable::DESCRIPTION_LENGTH_MAX if new_record? && description.length > Issuable::DESCRIPTION_LENGTH_MAX
errors.add(:description, :too_long, count: Issuable::DESCRIPTION_LENGTH_MAX) errors.add(:description, :too_long, count: Issuable::DESCRIPTION_LENGTH_MAX)
@ -332,10 +308,6 @@ module Issuable
project project
end end
def milestone_available?
project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group)
end
def assignee_or_author?(user) def assignee_or_author?(user)
author_id == user.id || assignees.exists?(user.id) author_id == user.id || assignees.exists?(user.id)
end end
@ -482,13 +454,6 @@ module Issuable
def wipless_title_changed(old_title) def wipless_title_changed(old_title)
old_title != title old_title != title
end end
##
# Overridden on EE module
#
def supports_milestone?
respond_to?(:milestone_id)
end
end end
Issuable.prepend_if_ee('EE::Issuable') # rubocop: disable Cop/InjectEnterpriseEditionModule Issuable.prepend_if_ee('EE::Issuable') # rubocop: disable Cop/InjectEnterpriseEditionModule

View file

@ -0,0 +1,62 @@
# frozen_string_literal: true
# == Milestoneable concern
#
# Contains functionality related to objects that can be assigned Milestones
#
# Used by Issuable
#
module Milestoneable
extend ActiveSupport::Concern
included do
belongs_to :milestone
validate :milestone_is_valid
after_save :write_to_new_milestone_relationship
scope :of_milestones, ->(ids) { where(milestone_id: ids) }
scope :any_milestone, -> { where('milestone_id IS NOT NULL') }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
scope :any_release, -> { joins_milestone_releases }
scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) }
scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) }
scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) }
scope :without_release, -> do
joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id")
.where('milestone_releases.release_id IS NULL')
end
scope :joins_milestone_releases, -> do
joins("JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id
JOIN releases ON milestone_releases.release_id = releases.id").distinct
end
private
def milestone_is_valid
errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available?
end
def write_to_new_milestone_relationship
self.milestones = [milestone].compact if supports_milestone? && saved_change_to_milestone_id?
end
end
def milestone_available?
project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group)
end
##
# Overridden on EE module
#
def supports_milestone?
respond_to?(:milestone_id)
end
end
Milestoneable.prepend_if_ee('EE::Milestoneable')

View file

@ -33,6 +33,9 @@ class Issue < ApplicationRecord
has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.issues&.maximum(:iid) } has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.issues&.maximum(:iid) }
has_many :issue_milestones
has_many :milestones, through: :issue_milestones
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :merge_requests_closing_issues, has_many :merge_requests_closing_issues,

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
class IssueMilestone < ApplicationRecord
belongs_to :milestone
belongs_to :issue
end

View file

@ -35,6 +35,9 @@ class MergeRequest < ApplicationRecord
has_many :merge_request_diffs has_many :merge_request_diffs
has_many :merge_request_milestones
has_many :milestones, through: :merge_request_milestones
has_one :merge_request_diff, has_one :merge_request_diff,
-> { order('merge_request_diffs.id DESC') }, inverse_of: :merge_request -> { order('merge_request_diffs.id DESC') }, inverse_of: :merge_request

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
class MergeRequestMilestone < ApplicationRecord
belongs_to :milestone
belongs_to :merge_request
end

View file

@ -38,6 +38,9 @@ class Milestone < ApplicationRecord
has_many :merge_requests has_many :merge_requests
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :issue_milestones
has_many :merge_request_milestones
scope :of_projects, ->(ids) { where(project_id: ids) } scope :of_projects, ->(ids) { where(project_id: ids) }
scope :of_groups, ->(ids) { where(group_id: ids) } scope :of_groups, ->(ids) { where(group_id: ids) }
scope :active, -> { with_state(:active) } scope :active, -> { with_state(:active) }

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class PipelineDetailsEntity < PipelineEntity class PipelineDetailsEntity < PipelineEntity
expose :project, using: ProjectEntity
expose :flags do expose :flags do
expose :latest?, as: :latest expose :latest?, as: :latest
end end

View file

@ -41,6 +41,7 @@ class PipelineSerializer < BaseSerializer
def preloaded_relations def preloaded_relations
[ [
:latest_statuses_ordered_by_stage, :latest_statuses_ordered_by_stage,
:project,
:stages, :stages,
{ {
failed_builds: %i(project metadata) failed_builds: %i(project metadata)

View file

@ -23,7 +23,7 @@ module Ci
Gitlab::Ci::Pipeline::Chain::Limit::JobActivity].freeze Gitlab::Ci::Pipeline::Chain::Limit::JobActivity].freeze
# rubocop: disable Metrics/ParameterLists # rubocop: disable Metrics/ParameterLists
def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, **options, &block) def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, bridge: nil, **options, &block)
@pipeline = Ci::Pipeline.new @pipeline = Ci::Pipeline.new
command = Gitlab::Ci::Pipeline::Chain::Command.new( command = Gitlab::Ci::Pipeline::Chain::Command.new(
@ -46,6 +46,7 @@ module Ci
current_user: current_user, current_user: current_user,
push_options: params[:push_options] || {}, push_options: params[:push_options] || {},
chat_data: params[:chat_data], chat_data: params[:chat_data],
bridge: bridge,
**extra_options(options)) **extra_options(options))
sequence = Gitlab::Ci::Pipeline::Chain::Sequence sequence = Gitlab::Ci::Pipeline::Chain::Sequence
@ -104,14 +105,14 @@ module Ci
if Feature.enabled?(:ci_support_interruptible_pipelines, project, default_enabled: true) if Feature.enabled?(:ci_support_interruptible_pipelines, project, default_enabled: true)
project.ci_pipelines project.ci_pipelines
.where(ref: pipeline.ref) .where(ref: pipeline.ref)
.where.not(id: pipeline.id) .where.not(id: pipeline.same_family_pipeline_ids)
.where.not(sha: project.commit(pipeline.ref).try(:id)) .where.not(sha: project.commit(pipeline.ref).try(:id))
.alive_or_scheduled .alive_or_scheduled
.with_only_interruptible_builds .with_only_interruptible_builds
else else
project.ci_pipelines project.ci_pipelines
.where(ref: pipeline.ref) .where(ref: pipeline.ref)
.where.not(id: pipeline.id) .where.not(id: pipeline.same_family_pipeline_ids)
.where.not(sha: project.commit(pipeline.ref).try(:id)) .where.not(sha: project.commit(pipeline.ref).try(:id))
.created_or_pending .created_or_pending
end end

View file

@ -44,7 +44,7 @@ module Ci
return error("400 Job has to be running", 400) unless job.running? return error("400 Job has to be running", 400) unless job.running?
pipeline = Ci::CreatePipelineService.new(project, job.user, ref: params[:ref]) pipeline = Ci::CreatePipelineService.new(project, job.user, ref: params[:ref])
.execute(:pipeline, ignore_skip_ci: true) do |pipeline| .execute(:cross_project_pipeline, ignore_skip_ci: true) do |pipeline|
source = job.sourced_pipelines.build( source = job.sourced_pipelines.build(
source_pipeline: job.pipeline, source_pipeline: job.pipeline,
source_project: job.project, source_project: job.project,

View file

@ -24,3 +24,9 @@
%strong= _('Signed in') %strong= _('Signed in')
= s_('ProfileSession|on') = s_('ProfileSession|on')
= l(active_session.created_at, format: :short) = l(active_session.created_at, format: :short)
- unless is_current_session
.float-right
= link_to profile_active_session_path(active_session.public_id), data: { confirm: _('Are you sure? The device will be signed out of GitLab.') }, method: :delete, class: "btn btn-danger prepend-left-10" do
%span.sr-only= _('Revoke')
= _('Revoke')

View file

@ -0,0 +1,3 @@
- if environment.auto_stop_at? && environment.available?
= button_to cancel_auto_stop_project_environment_path(environment.project, environment), class: 'btn btn-secondary has-tooltip', title: _('Prevent environment from auto-stopping') do
= sprite_icon('thumbtack')

View file

@ -32,9 +32,14 @@
= button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do = button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do
= s_('Environments|Stop environment') = s_('Environments|Stop environment')
.top-area .top-area.justify-content-between
%h3.page-title= @environment.name .d-flex
.nav-controls.ml-auto.my-2 %h3.page-title= @environment.name
- if @environment.auto_stop_at?
%p.align-self-end.prepend-left-8
= s_('Environments|Auto stops %{auto_stop_time}').html_safe % {auto_stop_time: time_ago_with_tooltip(@environment.auto_stop_at)}
.nav-controls.my-2
= render 'projects/environments/pin_button', environment: @environment
= render 'projects/environments/terminal_button', environment: @environment = render 'projects/environments/terminal_button', environment: @environment
= render 'projects/environments/external_url', environment: @environment = render 'projects/environments/external_url', environment: @environment
= render 'projects/environments/metrics_button', environment: @environment = render 'projects/environments/metrics_button', environment: @environment

View file

@ -4,6 +4,9 @@
%h4.sub-header %h4.sub-header
= _("Programming languages used in this repository") = _("Programming languages used in this repository")
%p
= _("Measured in bytes of code. Excludes generated and vendored code.")
.row .row
.col-md-4 .col-md-4
%ul.bordered-list %ul.bordered-list

View file

@ -0,0 +1,101 @@
# frozen_string_literal: true
#
# A concern that helps run exactly one instance of a worker, over and over,
# until it returns false or raises.
#
# To ensure the worker is always up, you can schedule it every minute with
# sidekiq-cron. Excess jobs will immediately exit due to an exclusive lease.
#
# The worker must define:
#
# - `#perform`
# - `#lease_timeout`
#
# The worker spec should include `it_behaves_like 'reenqueuer'` and
# `it_behaves_like 'it is rate limited to 1 call per'`.
#
# Optionally override `#minimum_duration` to adjust the rate limit.
#
# When `#perform` returns false, the job will not be reenqueued. Instead, we
# will wait for the next one scheduled by sidekiq-cron.
#
# #lease_timeout should be longer than the longest possible `#perform`.
# The lease is normally released in an ensure block, but it is possible to
# orphan the lease by killing Sidekiq, so it should also be as short as
# possible. Consider that long-running jobs are generally not recommended.
# Ideally, every job finishes within 25 seconds because that is the default
# wait time for graceful termination.
#
# Timing: It runs as often as Sidekiq allows. We rate limit with sleep for
# now: https://gitlab.com/gitlab-org/gitlab/issues/121697
module Reenqueuer
extend ActiveSupport::Concern
prepended do
include ExclusiveLeaseGuard
include ReenqueuerSleeper
sidekiq_options retry: false
end
def perform(*args)
try_obtain_lease do
reenqueue(*args) do
ensure_minimum_duration(minimum_duration) do
super
end
end
end
end
private
def reenqueue(*args)
self.class.perform_async(*args) if yield
end
# Override as needed
def minimum_duration
5.seconds
end
# We intend to get rid of sleep:
# https://gitlab.com/gitlab-org/gitlab/issues/121697
module ReenqueuerSleeper
# The block will run, and then sleep until the minimum duration. Returns the
# block's return value.
#
# Usage:
#
# ensure_minimum_duration(5.seconds) do
# # do something
# end
#
def ensure_minimum_duration(minimum_duration)
start_time = Time.now
result = yield
sleep_if_time_left(minimum_duration, start_time)
result
end
private
def sleep_if_time_left(minimum_duration, start_time)
time_left = calculate_time_left(minimum_duration, start_time)
sleep(time_left) if time_left > 0
end
def calculate_time_left(minimum_duration, start_time)
minimum_duration - elapsed_time(start_time)
end
def elapsed_time(start_time)
Time.now - start_time
end
end
end

View file

@ -0,0 +1,5 @@
---
title: Auto stop environments after a certain period
merge_request: 20372
author:
type: added

View file

@ -0,0 +1,6 @@
---
title: Restores user's ability to revoke sessions from the active sessions
page.
merge_request: 17462
author: Jesse Hall @jessehall3
type: changed

View file

@ -0,0 +1,5 @@
---
title: Setup storage for multiple milestones
merge_request: 22043
author:
type: added

View file

@ -0,0 +1,5 @@
---
title: Allow an upstream pipeline to create a downstream pipeline in the same project
merge_request: 20930
author:
type: added

View file

@ -0,0 +1,5 @@
---
title: Add measurement details for programming languages graph
merge_request: 20592
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: 25968-activity-filter-to-notes-api
merge_request: 21159
author: jhenkens
type: added

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
class SupportMultipleMilestonesForIssues < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
create_table :issue_milestones, id: false do |t|
t.references :issue, foreign_key: { on_delete: :cascade }, index: { unique: true }, null: false
t.references :milestone, foreign_key: { on_delete: :cascade }, index: true, null: false
end
add_index :issue_milestones, [:issue_id, :milestone_id], unique: true
end
end

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
class SupportMultipleMilestonesForMergeRequests < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
create_table :merge_request_milestones, id: false do |t|
t.references :merge_request, foreign_key: { on_delete: :cascade }, index: { unique: true }, null: false
t.references :milestone, foreign_key: { on_delete: :cascade }, index: true, null: false
end
add_index :merge_request_milestones, [:merge_request_id, :milestone_id], name: 'index_mrs_milestones_on_mr_id_and_milestone_id', unique: true
end
end

View file

@ -2099,6 +2099,14 @@ ActiveRecord::Schema.define(version: 2019_12_29_140154) do
t.index ["issue_id"], name: "index_issue_metrics" t.index ["issue_id"], name: "index_issue_metrics"
end end
create_table "issue_milestones", id: false, force: :cascade do |t|
t.bigint "issue_id", null: false
t.bigint "milestone_id", null: false
t.index ["issue_id", "milestone_id"], name: "index_issue_milestones_on_issue_id_and_milestone_id", unique: true
t.index ["issue_id"], name: "index_issue_milestones_on_issue_id", unique: true
t.index ["milestone_id"], name: "index_issue_milestones_on_milestone_id"
end
create_table "issue_tracker_data", force: :cascade do |t| create_table "issue_tracker_data", force: :cascade do |t|
t.integer "service_id", null: false t.integer "service_id", null: false
t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "created_at", null: false
@ -2486,6 +2494,14 @@ ActiveRecord::Schema.define(version: 2019_12_29_140154) do
t.index ["pipeline_id"], name: "index_merge_request_metrics_on_pipeline_id" t.index ["pipeline_id"], name: "index_merge_request_metrics_on_pipeline_id"
end end
create_table "merge_request_milestones", id: false, force: :cascade do |t|
t.bigint "merge_request_id", null: false
t.bigint "milestone_id", null: false
t.index ["merge_request_id", "milestone_id"], name: "index_mrs_milestones_on_mr_id_and_milestone_id", unique: true
t.index ["merge_request_id"], name: "index_merge_request_milestones_on_merge_request_id", unique: true
t.index ["milestone_id"], name: "index_merge_request_milestones_on_milestone_id"
end
create_table "merge_request_user_mentions", force: :cascade do |t| create_table "merge_request_user_mentions", force: :cascade do |t|
t.integer "merge_request_id", null: false t.integer "merge_request_id", null: false
t.integer "note_id" t.integer "note_id"
@ -4595,6 +4611,8 @@ ActiveRecord::Schema.define(version: 2019_12_29_140154) do
add_foreign_key "issue_links", "issues", column: "source_id", name: "fk_c900194ff2", on_delete: :cascade add_foreign_key "issue_links", "issues", column: "source_id", name: "fk_c900194ff2", on_delete: :cascade
add_foreign_key "issue_links", "issues", column: "target_id", name: "fk_e71bb44f1f", on_delete: :cascade add_foreign_key "issue_links", "issues", column: "target_id", name: "fk_e71bb44f1f", on_delete: :cascade
add_foreign_key "issue_metrics", "issues", on_delete: :cascade add_foreign_key "issue_metrics", "issues", on_delete: :cascade
add_foreign_key "issue_milestones", "issues", on_delete: :cascade
add_foreign_key "issue_milestones", "milestones", on_delete: :cascade
add_foreign_key "issue_tracker_data", "services", on_delete: :cascade add_foreign_key "issue_tracker_data", "services", on_delete: :cascade
add_foreign_key "issue_user_mentions", "issues", on_delete: :cascade add_foreign_key "issue_user_mentions", "issues", on_delete: :cascade
add_foreign_key "issue_user_mentions", "notes", on_delete: :cascade add_foreign_key "issue_user_mentions", "notes", on_delete: :cascade
@ -4638,6 +4656,8 @@ ActiveRecord::Schema.define(version: 2019_12_29_140154) do
add_foreign_key "merge_request_metrics", "merge_requests", on_delete: :cascade add_foreign_key "merge_request_metrics", "merge_requests", on_delete: :cascade
add_foreign_key "merge_request_metrics", "users", column: "latest_closed_by_id", name: "fk_ae440388cc", on_delete: :nullify add_foreign_key "merge_request_metrics", "users", column: "latest_closed_by_id", name: "fk_ae440388cc", on_delete: :nullify
add_foreign_key "merge_request_metrics", "users", column: "merged_by_id", name: "fk_7f28d925f3", on_delete: :nullify add_foreign_key "merge_request_metrics", "users", column: "merged_by_id", name: "fk_7f28d925f3", on_delete: :nullify
add_foreign_key "merge_request_milestones", "merge_requests", on_delete: :cascade
add_foreign_key "merge_request_milestones", "milestones", on_delete: :cascade
add_foreign_key "merge_request_user_mentions", "merge_requests", on_delete: :cascade add_foreign_key "merge_request_user_mentions", "merge_requests", on_delete: :cascade
add_foreign_key "merge_request_user_mentions", "notes", on_delete: :cascade add_foreign_key "merge_request_user_mentions", "notes", on_delete: :cascade
add_foreign_key "merge_requests", "ci_pipelines", column: "head_pipeline_id", name: "fk_fd82eae0b9", on_delete: :nullify add_foreign_key "merge_requests", "ci_pipelines", column: "head_pipeline_id", name: "fk_fd82eae0b9", on_delete: :nullify

View file

@ -2665,7 +2665,7 @@ the currently running/pending `deploy-to-production` job is finished. As a resul
you can ensure that concurrent deployments will never happen to the production environment. you can ensure that concurrent deployments will never happen to the production environment.
There can be multiple `resource_group`s defined per environment. A good use case for this There can be multiple `resource_group`s defined per environment. A good use case for this
is when deploying to physical devices. You may have more than one phyisical device, and each is when deploying to physical devices. You may have more than one physical device, and each
one can be deployed to, but there can be only one deployment per device at any given time. one can be deployed to, but there can be only one deployment per device at any given time.
### `include` ### `include`

View file

@ -24,6 +24,11 @@ review the sessions, and revoke any you don't recognize.
GitLab allows users to have up to 100 active sessions at once. If the number of active sessions GitLab allows users to have up to 100 active sessions at once. If the number of active sessions
exceeds 100, the oldest ones are deleted. exceeds 100, the oldest ones are deleted.
## Revoking a session
1. Use the previous steps to navigate to **Active Sessions**.
1. Click on **Revoke** besides a session. The current session cannot be revoked, as this would sign you out of GitLab.
<!-- ## Troubleshooting <!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues Include any troubleshooting steps that you can foresee. If you know beforehand what issues

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -24,6 +24,8 @@ module API
desc: 'Return notes ordered by `created_at` or `updated_at` fields.' desc: 'Return notes ordered by `created_at` or `updated_at` fields.'
optional :sort, type: String, values: %w[asc desc], default: 'desc', optional :sort, type: String, values: %w[asc desc], default: 'desc',
desc: 'Return notes sorted in `asc` or `desc` order.' desc: 'Return notes sorted in `asc` or `desc` order.'
optional :activity_filter, type: String, values: UserPreference::NOTES_FILTERS.stringify_keys.keys, default: 'all_notes',
desc: 'The type of notables which are returned.'
use :pagination use :pagination
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
@ -35,7 +37,8 @@ module API
# at the DB query level (which we cannot in that case), the current # at the DB query level (which we cannot in that case), the current
# page can have less elements than :per_page even if # page can have less elements than :per_page even if
# there's more than one page. # there's more than one page.
raw_notes = noteable.notes.with_metadata.reorder(order_options_with_tie_breaker) notes_filter = UserPreference::NOTES_FILTERS[params[:activity_filter].to_sym]
raw_notes = noteable.notes.with_metadata.with_notes_filter(notes_filter).reorder(order_options_with_tie_breaker)
# paginate() only works with a relation. This could lead to a # paginate() only works with a relation. This could lead to a
# mismatch between the pagination headers info and the actual notes # mismatch between the pagination headers info and the actual notes

View file

@ -10,7 +10,7 @@ module Gitlab
:trigger_request, :schedule, :merge_request, :external_pull_request, :trigger_request, :schedule, :merge_request, :external_pull_request,
:ignore_skip_ci, :save_incompleted, :ignore_skip_ci, :save_incompleted,
:seeds_block, :variables_attributes, :push_options, :seeds_block, :variables_attributes, :push_options,
:chat_data, :allow_mirror_update, :chat_data, :allow_mirror_update, :bridge,
# These attributes are set by Chains during processing: # These attributes are set by Chains during processing:
:config_content, :config_processor, :stage_seeds :config_content, :config_processor, :stage_seeds
) do ) do

View file

@ -9,7 +9,7 @@ module Gitlab
include Chain::Helpers include Chain::Helpers
SOURCES = [ SOURCES = [
Gitlab::Ci::Pipeline::Chain::Config::Content::Runtime, Gitlab::Ci::Pipeline::Chain::Config::Content::Bridge,
Gitlab::Ci::Pipeline::Chain::Config::Content::Repository, Gitlab::Ci::Pipeline::Chain::Config::Content::Repository,
Gitlab::Ci::Pipeline::Chain::Config::Content::ExternalProject, Gitlab::Ci::Pipeline::Chain::Config::Content::ExternalProject,
Gitlab::Ci::Pipeline::Chain::Config::Content::Remote, Gitlab::Ci::Pipeline::Chain::Config::Content::Remote,
@ -17,7 +17,7 @@ module Gitlab
].freeze ].freeze
LEGACY_SOURCES = [ LEGACY_SOURCES = [
Gitlab::Ci::Pipeline::Chain::Config::Content::Runtime, Gitlab::Ci::Pipeline::Chain::Config::Content::Bridge,
Gitlab::Ci::Pipeline::Chain::Config::Content::LegacyRepository, Gitlab::Ci::Pipeline::Chain::Config::Content::LegacyRepository,
Gitlab::Ci::Pipeline::Chain::Config::Content::LegacyAutoDevops Gitlab::Ci::Pipeline::Chain::Config::Content::LegacyAutoDevops
].freeze ].freeze

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Gitlab
module Ci
module Pipeline
module Chain
module Config
class Content
class Bridge < Source
def content
return unless @command.bridge
@command.bridge.yaml_for_downstream
end
def source
:bridge_source
end
end
end
end
end
end
end
end

View file

@ -1,30 +0,0 @@
# frozen_string_literal: true
module Gitlab
module Ci
module Pipeline
module Chain
module Config
class Content
class Runtime < Source
def content
@command.config_content
end
def source
# The only case when this source is used is when the config content
# is passed in as parameter to Ci::CreatePipelineService.
# This would only occur with parent/child pipelines which is being
# implemented.
# TODO: change source to return :runtime_source
# https://gitlab.com/gitlab-org/gitlab/merge_requests/21041
nil
end
end
end
end
end
end
end
end

View file

@ -24,6 +24,8 @@ tree:
- milestone: - milestone:
- events: - events:
- :push_event_payload - :push_event_payload
- issue_milestones:
- :milestone
- resource_label_events: - resource_label_events:
- label: - label:
- :priorities - :priorities
@ -57,6 +59,8 @@ tree:
- milestone: - milestone:
- events: - events:
- :push_event_payload - :push_event_payload
- merge_request_milestones:
- :milestone
- resource_label_events: - resource_label_events:
- label: - label:
- :priorities - :priorities
@ -202,6 +206,12 @@ excluded_attributes:
- :latest_merge_request_diff_id - :latest_merge_request_diff_id
- :head_pipeline_id - :head_pipeline_id
- :state_id - :state_id
issue_milestones:
- :milestone_id
- :issue_id
merge_request_milestones:
- :milestone_id
- :merge_request_id
award_emoji: award_emoji:
- :awardable_id - :awardable_id
statuses: statuses:

View file

@ -2152,6 +2152,9 @@ msgstr ""
msgid "Are you sure? Removing this GPG key does not affect already signed commits." msgid "Are you sure? Removing this GPG key does not affect already signed commits."
msgstr "" msgstr ""
msgid "Are you sure? The device will be signed out of GitLab."
msgstr ""
msgid "Are you sure? This will invalidate your registered applications and U2F devices." msgid "Are you sure? This will invalidate your registered applications and U2F devices."
msgstr "" msgstr ""
@ -6816,6 +6819,9 @@ msgstr ""
msgid "EnvironmentsDashboard|The environments dashboard provides a summary of each project's environments' status, including pipeline and alert statuses." msgid "EnvironmentsDashboard|The environments dashboard provides a summary of each project's environments' status, including pipeline and alert statuses."
msgstr "" msgstr ""
msgid "Environments|An error occurred while canceling the auto stop, please try again"
msgstr ""
msgid "Environments|An error occurred while fetching the environments." msgid "Environments|An error occurred while fetching the environments."
msgstr "" msgstr ""
@ -6834,6 +6840,12 @@ msgstr ""
msgid "Environments|Are you sure you want to stop this environment?" msgid "Environments|Are you sure you want to stop this environment?"
msgstr "" msgstr ""
msgid "Environments|Auto stop in"
msgstr ""
msgid "Environments|Auto stops %{auto_stop_time}"
msgstr ""
msgid "Environments|Commit" msgid "Environments|Commit"
msgstr "" msgstr ""
@ -11121,6 +11133,9 @@ msgstr ""
msgid "May" msgid "May"
msgstr "" msgstr ""
msgid "Measured in bytes of code. Excludes generated and vendored code."
msgstr ""
msgid "Median" msgid "Median"
msgstr "" msgstr ""
@ -13329,6 +13344,9 @@ msgstr ""
msgid "Prevent approval of merge requests by merge request committers" msgid "Prevent approval of merge requests by merge request committers"
msgstr "" msgstr ""
msgid "Prevent environment from auto-stopping"
msgstr ""
msgid "Preview" msgid "Preview"
msgstr "" msgstr ""

View file

@ -72,3 +72,4 @@ Disallow: /*/*/protected_branches
Disallow: /*/*/uploads/ Disallow: /*/*/uploads/
Disallow: /*/-/group_members Disallow: /*/-/group_members
Disallow: /*/project_members Disallow: /*/project_members
Disallow: /groups/*/-/analytics

View file

@ -84,4 +84,31 @@ describe 'Profile > Active Sessions', :clean_gitlab_redis_shared_state do
expect(page).not_to have_content('Chrome on Windows') expect(page).not_to have_content('Chrome on Windows')
end end
end end
it 'User can revoke a session', :js, :redis_session_store do
Capybara::Session.new(:session1)
Capybara::Session.new(:session2)
# set an additional session in another browser
using_session :session2 do
gitlab_sign_in(user)
end
using_session :session1 do
gitlab_sign_in(user)
visit profile_active_sessions_path
expect(page).to have_link('Revoke', count: 1)
accept_confirm { click_on 'Revoke' }
expect(page).not_to have_link('Revoke')
end
using_session :session2 do
visit profile_active_sessions_path
expect(page).to have_content('You need to sign in or sign up before continuing.')
end
end
end end

View file

@ -12,6 +12,10 @@ describe 'Environment' do
project.add_role(user, role) project.add_role(user, role)
end end
def auto_stop_button_selector
%q{button[title="Prevent environment from auto-stopping"]}
end
describe 'environment details page' do describe 'environment details page' do
let!(:environment) { create(:environment, project: project) } let!(:environment) { create(:environment, project: project) }
let!(:permissions) { } let!(:permissions) { }
@ -27,6 +31,40 @@ describe 'Environment' do
expect(page).to have_content(environment.name) expect(page).to have_content(environment.name)
end end
context 'without auto-stop' do
it 'does not show auto-stop text' do
expect(page).not_to have_content('Auto stops')
end
it 'does not show auto-stop button' do
expect(page).not_to have_selector(auto_stop_button_selector)
end
end
context 'with auto-stop' do
let!(:environment) { create(:environment, :will_auto_stop, name: 'staging', project: project) }
before do
visit_environment(environment)
end
it 'shows auto stop info' do
expect(page).to have_content('Auto stops')
end
it 'shows auto stop button' do
expect(page).to have_selector(auto_stop_button_selector)
expect(page.find(auto_stop_button_selector).find(:xpath, '..')['action']).to have_content(cancel_auto_stop_project_environment_path(environment.project, environment))
end
it 'allows user to cancel auto stop', :js do
page.find(auto_stop_button_selector).click
wait_for_all_requests
expect(page).to have_content('Auto stop successfully canceled.')
expect(page).not_to have_selector(auto_stop_button_selector)
end
end
context 'without deployments' do context 'without deployments' do
it 'does not show deployments' do it 'does not show deployments' do
expect(page).to have_content('You don\'t have any deployments right now.') expect(page).to have_content('You don\'t have any deployments right now.')

View file

@ -64,6 +64,19 @@ describe PipelinesFinder do
end end
end end
context 'when project has child pipelines' do
let!(:parent_pipeline) { create(:ci_pipeline, project: project) }
let!(:child_pipeline) { create(:ci_pipeline, project: project, source: :parent_pipeline) }
let!(:pipeline_source) do
create(:ci_sources_pipeline, pipeline: child_pipeline, source_pipeline: parent_pipeline)
end
it 'filters out child pipelines and show only the parents' do
is_expected.to eq([parent_pipeline])
end
end
HasStatus::AVAILABLE_STATUSES.each do |target| HasStatus::AVAILABLE_STATUSES.each do |target|
context "when status is #{target}" do context "when status is #{target}" do
let(:params) { { status: target } } let(:params) { { status: target } }

View file

@ -1,6 +1,8 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { format } from 'timeago.js'; import { format } from 'timeago.js';
import EnvironmentItem from '~/environments/components/environment_item.vue'; import EnvironmentItem from '~/environments/components/environment_item.vue';
import PinComponent from '~/environments/components/environment_pin.vue';
import { environment, folder, tableData } from './mock_data'; import { environment, folder, tableData } from './mock_data';
describe('Environment item', () => { describe('Environment item', () => {
@ -26,6 +28,8 @@ describe('Environment item', () => {
}); });
}); });
const findAutoStop = () => wrapper.find('.js-auto-stop');
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
@ -77,6 +81,79 @@ describe('Environment item', () => {
expect(wrapper.find('.js-commit-component')).toBeDefined(); expect(wrapper.find('.js-commit-component')).toBeDefined();
}); });
}); });
describe('Without auto-stop date', () => {
beforeEach(() => {
factory({
propsData: {
model: environment,
canReadEnvironment: true,
tableData,
shouldShowAutoStopDate: true,
},
});
});
it('should not render a date', () => {
expect(findAutoStop().exists()).toBe(false);
});
it('should not render the suto-stop button', () => {
expect(wrapper.find(PinComponent).exists()).toBe(false);
});
});
describe('With auto-stop date', () => {
describe('in the future', () => {
const futureDate = new Date(Date.now() + 100000);
beforeEach(() => {
factory({
propsData: {
model: {
...environment,
auto_stop_at: futureDate,
},
canReadEnvironment: true,
tableData,
shouldShowAutoStopDate: true,
},
});
});
it('renders the date', () => {
expect(findAutoStop().text()).toContain(format(futureDate));
});
it('should render the auto-stop button', () => {
expect(wrapper.find(PinComponent).exists()).toBe(true);
});
});
describe('in the past', () => {
const pastDate = new Date(Date.now() - 100000);
beforeEach(() => {
factory({
propsData: {
model: {
...environment,
auto_stop_at: pastDate,
},
canReadEnvironment: true,
tableData,
shouldShowAutoStopDate: true,
},
});
});
it('should not render a date', () => {
expect(findAutoStop().exists()).toBe(false);
});
it('should not render the suto-stop button', () => {
expect(wrapper.find(PinComponent).exists()).toBe(false);
});
});
});
}); });
describe('With manual actions', () => { describe('With manual actions', () => {

View file

@ -0,0 +1,46 @@
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/environments/event_hub';
import PinComponent from '~/environments/components/environment_pin.vue';
describe('Pin Component', () => {
let wrapper;
const factory = (options = {}) => {
// This destroys any wrappers created before a nested call to factory reassigns it
if (wrapper && wrapper.destroy) {
wrapper.destroy();
}
wrapper = shallowMount(PinComponent, {
...options,
});
};
const autoStopUrl = '/root/auto-stop-env-test/-/environments/38/cancel_auto_stop';
beforeEach(() => {
factory({
propsData: {
autoStopUrl,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('should render the component with thumbtack icon', () => {
expect(wrapper.find(Icon).props('name')).toBe('thumbtack');
});
it('should emit onPinClick when clicked', () => {
const eventHubSpy = jest.spyOn(eventHub, '$emit');
const button = wrapper.find(GlButton);
button.vm.$emit('click');
expect(eventHubSpy).toHaveBeenCalledWith('cancelAutoStop', autoStopUrl);
});
});

View file

@ -63,6 +63,7 @@ const environment = {
log_path: 'root/ci-folders/environments/31/logs', log_path: 'root/ci-folders/environments/31/logs',
created_at: '2016-11-07T11:11:16.525Z', created_at: '2016-11-07T11:11:16.525Z',
updated_at: '2016-11-10T15:55:58.778Z', updated_at: '2016-11-10T15:55:58.778Z',
auto_stop_at: null,
}; };
const folder = { const folder = {
@ -98,6 +99,10 @@ const tableData = {
title: 'Updated', title: 'Updated',
spacing: 'section-10', spacing: 'section-10',
}, },
autoStop: {
title: 'Auto stop in',
spacing: 'section-5',
},
actions: { actions: {
spacing: 'section-25', spacing: 'section-25',
}, },

View file

@ -15,6 +15,42 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do
stub_feature_flags(ci_root_config_content: false) stub_feature_flags(ci_root_config_content: false)
end end
context 'when bridge job is passed in as parameter' do
let(:ci_config_path) { nil }
let(:bridge) { create(:ci_bridge) }
before do
command.bridge = bridge
end
context 'when bridge job has downstream yaml' do
before do
allow(bridge).to receive(:yaml_for_downstream).and_return('the-yaml')
end
it 'returns the content already available in command' do
subject.perform!
expect(pipeline.config_source).to eq 'bridge_source'
expect(command.config_content).to eq 'the-yaml'
end
end
context 'when bridge job does not have downstream yaml' do
before do
allow(bridge).to receive(:yaml_for_downstream).and_return(nil)
end
it 'returns the next available source' do
subject.perform!
expect(pipeline.config_source).to eq 'auto_devops_source'
template = Gitlab::Template::GitlabCiYmlTemplate.find('Beta/Auto-DevOps')
expect(command.config_content).to eq(template.content)
end
end
end
context 'when config is defined in a custom path in the repository' do context 'when config is defined in a custom path in the repository' do
let(:ci_config_path) { 'path/to/config.yml' } let(:ci_config_path) { 'path/to/config.yml' }
@ -135,6 +171,23 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do
end end
end end
context 'when bridge job is passed in as parameter' do
let(:ci_config_path) { nil }
let(:bridge) { create(:ci_bridge) }
before do
command.bridge = bridge
allow(bridge).to receive(:yaml_for_downstream).and_return('the-yaml')
end
it 'returns the content already available in command' do
subject.perform!
expect(pipeline.config_source).to eq 'bridge_source'
expect(command.config_content).to eq 'the-yaml'
end
end
context 'when config is defined in a custom path in the repository' do context 'when config is defined in a custom path in the repository' do
let(:ci_config_path) { 'path/to/config.yml' } let(:ci_config_path) { 'path/to/config.yml' }
let(:config_content_result) do let(:config_content_result) do

View file

@ -6,6 +6,8 @@ issues:
- assignees - assignees
- updated_by - updated_by
- milestone - milestone
- issue_milestones
- milestones
- notes - notes
- resource_label_events - resource_label_events
- resource_weight_events - resource_weight_events
@ -78,6 +80,8 @@ milestone:
- boards - boards
- milestone_releases - milestone_releases
- releases - releases
- issue_milestones
- merge_request_milestones
snippets: snippets:
- author - author
- project - project
@ -106,6 +110,8 @@ merge_requests:
- assignee - assignee
- updated_by - updated_by
- milestone - milestone
- merge_request_milestones
- milestones
- notes - notes
- resource_label_events - resource_label_events
- label_links - label_links
@ -146,6 +152,12 @@ merge_requests:
- deployment_merge_requests - deployment_merge_requests
- deployments - deployments
- user_mentions - user_mentions
issue_milestones:
- milestone
- issue
merge_request_milestones:
- milestone
- merge_request
external_pull_requests: external_pull_requests:
- project - project
merge_request_diff: merge_request_diff:
@ -189,6 +201,8 @@ ci_pipelines:
- sourced_pipelines - sourced_pipelines
- triggered_by_pipeline - triggered_by_pipeline
- triggered_pipelines - triggered_pipelines
- child_pipelines
- parent_pipeline
- downstream_bridges - downstream_bridges
- job_artifacts - job_artifacts
- vulnerabilities_occurrence_pipelines - vulnerabilities_occurrence_pipelines

View file

@ -44,6 +44,19 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
end end
end end
describe '#public_id' do
it 'returns an encrypted, url-encoded session id' do
original_session_id = "!*'();:@&\n=+$,/?%abcd#123[4567]8"
active_session = ActiveSession.new(session_id: original_session_id)
encrypted_encoded_id = active_session.public_id
encrypted_id = CGI.unescape(encrypted_encoded_id)
derived_session_id = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_id)
expect(original_session_id).to eq derived_session_id
end
end
describe '.list' do describe '.list' do
it 'returns all sessions by user' do it 'returns all sessions by user' do
Gitlab::Redis::SharedState.with do |redis| Gitlab::Redis::SharedState.with do |redis|
@ -173,8 +186,7 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
device_name: 'iPhone 6', device_name: 'iPhone 6',
device_type: 'smartphone', device_type: 'smartphone',
created_at: Time.zone.parse('2018-03-12 09:06'), created_at: Time.zone.parse('2018-03-12 09:06'),
updated_at: Time.zone.parse('2018-03-12 09:06'), updated_at: Time.zone.parse('2018-03-12 09:06')
session_id: '6919a6f1bb119dd7396fadc38fd18d0d'
) )
end end
end end
@ -244,6 +256,40 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
end end
end end
describe '.destroy_with_public_id' do
it 'receives a user and public id and destroys the associated session' do
ActiveSession.set(user, request)
session = ActiveSession.list(user).first
ActiveSession.destroy_with_public_id(user, session.public_id)
total_sessions = ActiveSession.list(user).count
expect(total_sessions).to eq 0
end
it 'handles invalid input for public id' do
expect do
ActiveSession.destroy_with_public_id(user, nil)
end.not_to raise_error
expect do
ActiveSession.destroy_with_public_id(user, "")
end.not_to raise_error
expect do
ActiveSession.destroy_with_public_id(user, "aaaaaaaa")
end.not_to raise_error
end
it 'does not attempt to destroy session when given invalid input for public id' do
expect(ActiveSession).not_to receive(:destroy)
ActiveSession.destroy_with_public_id(user, nil)
ActiveSession.destroy_with_public_id(user, "")
ActiveSession.destroy_with_public_id(user, "aaaaaaaa")
end
end
describe '.cleanup' do describe '.cleanup' do
before do before do
stub_const("ActiveSession::ALLOWED_NUMBER_OF_ACTIVE_SESSIONS", 5) stub_const("ActiveSession::ALLOWED_NUMBER_OF_ACTIVE_SESSIONS", 5)

View file

@ -2716,4 +2716,114 @@ describe Ci::Pipeline, :mailer do
end end
end end
end end
describe '#parent_pipeline' do
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
context 'when pipeline is triggered by a pipeline from the same project' do
let(:upstream_pipeline) { create(:ci_pipeline, project: pipeline.project) }
before do
create(:ci_sources_pipeline,
source_pipeline: upstream_pipeline,
source_project: project,
pipeline: pipeline,
project: project)
end
it 'returns the parent pipeline' do
expect(pipeline.parent_pipeline).to eq(upstream_pipeline)
end
it 'is child' do
expect(pipeline).to be_child
end
end
context 'when pipeline is triggered by a pipeline from another project' do
let(:upstream_pipeline) { create(:ci_pipeline) }
before do
create(:ci_sources_pipeline,
source_pipeline: upstream_pipeline,
source_project: upstream_pipeline.project,
pipeline: pipeline,
project: project)
end
it 'returns nil' do
expect(pipeline.parent_pipeline).to be_nil
end
it 'is not child' do
expect(pipeline).not_to be_child
end
end
context 'when pipeline is not triggered by a pipeline' do
it 'returns nil' do
expect(pipeline.parent_pipeline).to be_nil
end
it 'is not child' do
expect(pipeline).not_to be_child
end
end
end
describe '#child_pipelines' do
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
context 'when pipeline triggered other pipelines on same project' do
let(:downstream_pipeline) { create(:ci_pipeline, project: pipeline.project) }
before do
create(:ci_sources_pipeline,
source_pipeline: pipeline,
source_project: pipeline.project,
pipeline: downstream_pipeline,
project: pipeline.project)
end
it 'returns the child pipelines' do
expect(pipeline.child_pipelines).to eq [downstream_pipeline]
end
it 'is parent' do
expect(pipeline).to be_parent
end
end
context 'when pipeline triggered other pipelines on another project' do
let(:downstream_pipeline) { create(:ci_pipeline) }
before do
create(:ci_sources_pipeline,
source_pipeline: pipeline,
source_project: pipeline.project,
pipeline: downstream_pipeline,
project: downstream_pipeline.project)
end
it 'returns empty array' do
expect(pipeline.child_pipelines).to be_empty
end
it 'is not parent' do
expect(pipeline).not_to be_parent
end
end
context 'when pipeline did not trigger any pipelines' do
it 'returns empty array' do
expect(pipeline.child_pipelines).to be_empty
end
it 'is not parent' do
expect(pipeline).not_to be_parent
end
end
end
end end

View file

@ -53,43 +53,6 @@ describe Issuable do
it_behaves_like 'validates description length with custom validation' it_behaves_like 'validates description length with custom validation'
it_behaves_like 'truncates the description to its allowed maximum length on import' it_behaves_like 'truncates the description to its allowed maximum length on import'
end end
describe 'milestone' do
let(:project) { create(:project) }
let(:milestone_id) { create(:milestone, project: project).id }
let(:params) do
{
title: 'something',
project: project,
author: build(:user),
milestone_id: milestone_id
}
end
subject { issuable_class.new(params) }
context 'with correct params' do
it { is_expected.to be_valid }
end
context 'with empty string milestone' do
let(:milestone_id) { '' }
it { is_expected.to be_valid }
end
context 'with nil milestone id' do
let(:milestone_id) { nil }
it { is_expected.to be_valid }
end
context 'with a milestone id from another project' do
let(:milestone_id) { create(:milestone).id }
it { is_expected.to be_invalid }
end
end
end end
describe "Scope" do describe "Scope" do
@ -141,48 +104,6 @@ describe Issuable do
end end
end end
describe '#milestone_available?' do
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
let(:issue) { create(:issue, project: project) }
def build_issuable(milestone_id)
issuable_class.new(project: project, milestone_id: milestone_id)
end
it 'returns true with a milestone from the issue project' do
milestone = create(:milestone, project: project)
expect(build_issuable(milestone.id).milestone_available?).to be_truthy
end
it 'returns true with a milestone from the issue project group' do
milestone = create(:milestone, group: group)
expect(build_issuable(milestone.id).milestone_available?).to be_truthy
end
it 'returns true with a milestone from the the parent of the issue project group' do
parent = create(:group)
group.update(parent: parent)
milestone = create(:milestone, group: parent)
expect(build_issuable(milestone.id).milestone_available?).to be_truthy
end
it 'returns false with a milestone from another project' do
milestone = create(:milestone)
expect(build_issuable(milestone.id).milestone_available?).to be_falsey
end
it 'returns false with a milestone from another group' do
milestone = create(:milestone, group: create(:group))
expect(build_issuable(milestone.id).milestone_available?).to be_falsey
end
end
describe ".search" do describe ".search" do
let!(:searchable_issue) { create(:issue, title: "Searchable awesome issue") } let!(:searchable_issue) { create(:issue, title: "Searchable awesome issue") }
let!(:searchable_issue2) { create(:issue, title: 'Aw') } let!(:searchable_issue2) { create(:issue, title: 'Aw') }
@ -809,27 +730,6 @@ describe Issuable do
end end
end end
describe '#supports_milestone?' do
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
context "for issues" do
let(:issue) { build(:issue, project: project) }
it 'returns true' do
expect(issue.supports_milestone?).to be_truthy
end
end
context "for merge requests" do
let(:merge_request) { build(:merge_request, target_project: project, source_project: project) }
it 'returns true' do
expect(merge_request.supports_milestone?).to be_truthy
end
end
end
describe '#matches_cross_reference_regex?' do describe '#matches_cross_reference_regex?' do
context "issue description with long path string" do context "issue description with long path string" do
let(:mentionable) { build(:issue, description: "/a" * 50000) } let(:mentionable) { build(:issue, description: "/a" * 50000) }
@ -854,91 +754,4 @@ describe Issuable do
it_behaves_like 'matches_cross_reference_regex? fails fast' it_behaves_like 'matches_cross_reference_regex? fails fast'
end end
end end
describe 'release scopes' do
let_it_be(:project) { create(:project) }
let(:forked_project) { fork_project(project) }
let_it_be(:release_1) { create(:release, tag: 'v1.0', project: project) }
let_it_be(:release_2) { create(:release, tag: 'v2.0', project: project) }
let_it_be(:release_3) { create(:release, tag: 'v3.0', project: project) }
let_it_be(:release_4) { create(:release, tag: 'v4.0', project: project) }
let_it_be(:milestone_1) { create(:milestone, releases: [release_1], title: 'm1', project: project) }
let_it_be(:milestone_2) { create(:milestone, releases: [release_1, release_2], title: 'm2', project: project) }
let_it_be(:milestone_3) { create(:milestone, releases: [release_2, release_4], title: 'm3', project: project) }
let_it_be(:milestone_4) { create(:milestone, releases: [release_3], title: 'm4', project: project) }
let_it_be(:milestone_5) { create(:milestone, releases: [release_3], title: 'm5', project: project) }
let_it_be(:milestone_6) { create(:milestone, title: 'm6', project: project) }
let_it_be(:issue_1) { create(:issue, milestone: milestone_1, project: project) }
let_it_be(:issue_2) { create(:issue, milestone: milestone_1, project: project) }
let_it_be(:issue_3) { create(:issue, milestone: milestone_2, project: project) }
let_it_be(:issue_4) { create(:issue, milestone: milestone_5, project: project) }
let_it_be(:issue_5) { create(:issue, milestone: milestone_6, project: project) }
let_it_be(:issue_6) { create(:issue, project: project) }
let(:mr_1) { create(:merge_request, milestone: milestone_1, target_project: project, source_project: project) }
let(:mr_2) { create(:merge_request, milestone: milestone_3, target_project: project, source_project: forked_project) }
let(:mr_3) { create(:merge_request, source_project: project) }
let_it_be(:issue_items) { Issue.all }
let(:mr_items) { MergeRequest.all }
describe '#without_release' do
it 'returns the issues or mrs not tied to any milestone and the ones tied to milestone with no release' do
expect(issue_items.without_release).to contain_exactly(issue_5, issue_6)
expect(mr_items.without_release).to contain_exactly(mr_3)
end
end
describe '#any_release' do
it 'returns all issues or all mrs tied to a release' do
expect(issue_items.any_release).to contain_exactly(issue_1, issue_2, issue_3, issue_4)
expect(mr_items.any_release).to contain_exactly(mr_1, mr_2)
end
end
describe '#with_release' do
it 'returns the issues tied to a specfic release' do
expect(issue_items.with_release('v1.0', project.id)).to contain_exactly(issue_1, issue_2, issue_3)
end
it 'returns the mrs tied to a specific release' do
expect(mr_items.with_release('v1.0', project.id)).to contain_exactly(mr_1)
end
context 'when a release has a milestone with one issue and another one with no issue' do
it 'returns that one issue' do
expect(issue_items.with_release('v2.0', project.id)).to contain_exactly(issue_3)
end
context 'when the milestone with no issue is added as a filter' do
it 'returns an empty list' do
expect(issue_items.with_release('v2.0', project.id).with_milestone('m3')).to be_empty
end
end
context 'when the milestone with the issue is added as a filter' do
it 'returns this issue' do
expect(issue_items.with_release('v2.0', project.id).with_milestone('m2')).to contain_exactly(issue_3)
end
end
end
context 'when there is no issue or mr under a specific release' do
it 'returns no issue or no mr' do
expect(issue_items.with_release('v4.0', project.id)).to be_empty
expect(mr_items.with_release('v4.0', project.id)).to be_empty
end
end
context 'when a non-existent release tag is passed in' do
it 'returns no issue or no mr' do
expect(issue_items.with_release('v999.0', project.id)).to be_empty
expect(mr_items.with_release('v999.0', project.id)).to be_empty
end
end
end
end
end end

View file

@ -0,0 +1,243 @@
# frozen_string_literal: true
require 'spec_helper'
describe Milestoneable do
let(:user) { create(:user) }
let(:milestone) { create(:milestone, project: project) }
shared_examples_for 'an object that can be assigned a milestone' do
describe 'Validation' do
describe 'milestone' do
let(:project) { create(:project, :repository) }
let(:milestone_id) { milestone.id }
subject { milestoneable_class.new(params) }
context 'with correct params' do
it { is_expected.to be_valid }
end
context 'with empty string milestone' do
let(:milestone_id) { '' }
it { is_expected.to be_valid }
end
context 'with nil milestone id' do
let(:milestone_id) { nil }
it { is_expected.to be_valid }
end
context 'with a milestone id from another project' do
let(:milestone_id) { create(:milestone).id }
it { is_expected.to be_invalid }
end
context 'when valid and saving' do
it 'copies the value to the new milestones relationship' do
subject.save!
expect(subject.milestones).to match_array([milestone])
end
context 'with old values in milestones relationship' do
let(:old_milestone) { create(:milestone, project: project) }
before do
subject.milestone = old_milestone
subject.save!
end
it 'replaces old values' do
expect(subject.milestones).to match_array([old_milestone])
subject.milestone = milestone
subject.save!
expect(subject.milestones).to match_array([milestone])
end
it 'can nullify the milestone' do
expect(subject.milestones).to match_array([old_milestone])
subject.milestone = nil
subject.save!
expect(subject.milestones).to match_array([])
end
end
end
end
end
describe '#milestone_available?' do
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
let(:issue) { create(:issue, project: project) }
def build_milestoneable(milestone_id)
milestoneable_class.new(project: project, milestone_id: milestone_id)
end
it 'returns true with a milestone from the issue project' do
milestone = create(:milestone, project: project)
expect(build_milestoneable(milestone.id).milestone_available?).to be_truthy
end
it 'returns true with a milestone from the issue project group' do
milestone = create(:milestone, group: group)
expect(build_milestoneable(milestone.id).milestone_available?).to be_truthy
end
it 'returns true with a milestone from the the parent of the issue project group' do
parent = create(:group)
group.update(parent: parent)
milestone = create(:milestone, group: parent)
expect(build_milestoneable(milestone.id).milestone_available?).to be_truthy
end
it 'returns false with a milestone from another project' do
milestone = create(:milestone)
expect(build_milestoneable(milestone.id).milestone_available?).to be_falsey
end
it 'returns false with a milestone from another group' do
milestone = create(:milestone, group: create(:group))
expect(build_milestoneable(milestone.id).milestone_available?).to be_falsey
end
end
end
describe '#supports_milestone?' do
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
context "for issues" do
let(:issue) { build(:issue, project: project) }
it 'returns true' do
expect(issue.supports_milestone?).to be_truthy
end
end
context "for merge requests" do
let(:merge_request) { build(:merge_request, target_project: project, source_project: project) }
it 'returns true' do
expect(merge_request.supports_milestone?).to be_truthy
end
end
end
describe 'release scopes' do
let_it_be(:project) { create(:project) }
let_it_be(:release_1) { create(:release, tag: 'v1.0', project: project) }
let_it_be(:release_2) { create(:release, tag: 'v2.0', project: project) }
let_it_be(:release_3) { create(:release, tag: 'v3.0', project: project) }
let_it_be(:release_4) { create(:release, tag: 'v4.0', project: project) }
let_it_be(:milestone_1) { create(:milestone, releases: [release_1], title: 'm1', project: project) }
let_it_be(:milestone_2) { create(:milestone, releases: [release_1, release_2], title: 'm2', project: project) }
let_it_be(:milestone_3) { create(:milestone, releases: [release_2, release_4], title: 'm3', project: project) }
let_it_be(:milestone_4) { create(:milestone, releases: [release_3], title: 'm4', project: project) }
let_it_be(:milestone_5) { create(:milestone, releases: [release_3], title: 'm5', project: project) }
let_it_be(:milestone_6) { create(:milestone, title: 'm6', project: project) }
let_it_be(:issue_1) { create(:issue, milestone: milestone_1, project: project) }
let_it_be(:issue_2) { create(:issue, milestone: milestone_1, project: project) }
let_it_be(:issue_3) { create(:issue, milestone: milestone_2, project: project) }
let_it_be(:issue_4) { create(:issue, milestone: milestone_5, project: project) }
let_it_be(:issue_5) { create(:issue, milestone: milestone_6, project: project) }
let_it_be(:issue_6) { create(:issue, project: project) }
let_it_be(:items) { Issue.all }
describe '#without_release' do
it 'returns the issues not tied to any milestone and the ones tied to milestone with no release' do
expect(items.without_release).to contain_exactly(issue_5, issue_6)
end
end
describe '#any_release' do
it 'returns all issues tied to a release' do
expect(items.any_release).to contain_exactly(issue_1, issue_2, issue_3, issue_4)
end
end
describe '#with_release' do
it 'returns the issues tied a specfic release' do
expect(items.with_release('v1.0', project.id)).to contain_exactly(issue_1, issue_2, issue_3)
end
context 'when a release has a milestone with one issue and another one with no issue' do
it 'returns that one issue' do
expect(items.with_release('v2.0', project.id)).to contain_exactly(issue_3)
end
context 'when the milestone with no issue is added as a filter' do
it 'returns an empty list' do
expect(items.with_release('v2.0', project.id).with_milestone('m3')).to be_empty
end
end
context 'when the milestone with the issue is added as a filter' do
it 'returns this issue' do
expect(items.with_release('v2.0', project.id).with_milestone('m2')).to contain_exactly(issue_3)
end
end
end
context 'when there is no issue under a specific release' do
it 'returns no issue' do
expect(items.with_release('v4.0', project.id)).to be_empty
end
end
context 'when a non-existent release tag is passed in' do
it 'returns no issue' do
expect(items.with_release('v999.0', project.id)).to be_empty
end
end
end
end
context 'Issues' do
let(:milestoneable_class) { Issue }
let(:params) do
{
title: 'something',
project: project,
author: user,
milestone_id: milestone_id
}
end
it_behaves_like 'an object that can be assigned a milestone'
end
context 'MergeRequests' do
let(:milestoneable_class) { MergeRequest }
let(:params) do
{
title: 'something',
source_project: project,
target_project: project,
source_branch: 'feature',
target_branch: 'master',
author: user,
milestone_id: milestone_id
}
end
it_behaves_like 'an object that can be assigned a milestone'
end
end

View file

@ -5,6 +5,12 @@ require 'spec_helper'
describe UserPreference do describe UserPreference do
let(:user_preference) { create(:user_preference) } let(:user_preference) { create(:user_preference) }
describe 'notes filters global keys' do
it 'contains expected values' do
expect(UserPreference::NOTES_FILTERS.keys).to match_array([:all_notes, :only_comments, :only_activity])
end
end
describe '#set_notes_filter' do describe '#set_notes_filter' do
let(:issuable) { build_stubbed(:issue) } let(:issuable) { build_stubbed(:issue) }

View file

@ -101,6 +101,75 @@ describe API::Notes do
expect(json_response.first['body']).to eq(cross_reference_note.note) expect(json_response.first['body']).to eq(cross_reference_note.note)
end end
end end
context "activity filters" do
let!(:user_reference_note) do
create :note,
noteable: ext_issue, project: ext_proj,
note: "Hello there general!",
system: false
end
let(:test_url) {"/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes"}
shared_examples 'a notes request' do
it 'is a note array response' do
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
end
end
context "when not provided" do
let(:count) { 2 }
before do
get api(test_url, private_user)
end
it_behaves_like 'a notes request'
it 'returns all the notes' do
expect(json_response.count).to eq(count)
end
end
context "when all_notes provided" do
let(:count) { 2 }
before do
get api(test_url + "?activity_filter=all_notes", private_user)
end
it_behaves_like 'a notes request'
it 'returns all the notes' do
expect(json_response.count).to eq(count)
end
end
context "when provided" do
using RSpec::Parameterized::TableSyntax
where(:filter, :count, :system_notable) do
"only_comments" | 1 | false
"only_activity" | 1 | true
end
with_them do
before do
get api(test_url + "?activity_filter=#{filter}", private_user)
end
it_behaves_like 'a notes request'
it "properly filters the returned notables" do
expect(json_response.count).to eq(count)
expect(json_response.first["system"]).to be system_notable
end
end
end
end
end end
describe "GET /projects/:id/noteable/:noteable_id/notes/:note_id" do describe "GET /projects/:id/noteable/:noteable_id/notes/:note_id" do

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
require 'spec_helper'
describe Ci::CreatePipelineService do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:admin) }
let(:ref) { 'refs/heads/master' }
let(:service) { described_class.new(project, user, { ref: ref }) }
context 'custom config content' do
let(:bridge) do
double(:bridge, yaml_for_downstream: <<~YML
rspec:
script: rspec
custom:
script: custom
YML
)
end
subject { service.execute(:push, bridge: bridge) }
it 'creates a pipeline using the content passed in as param' do
expect(subject).to be_persisted
expect(subject.builds.map(&:name)).to eq %w[rspec custom]
expect(subject.config_source).to eq 'bridge_source'
end
end
end

View file

@ -0,0 +1,116 @@
# frozen_string_literal: true
# Expects `worker_class` to be defined
shared_examples_for 'reenqueuer' do
subject(:job) { worker_class.new }
before do
allow(job).to receive(:sleep) # faster tests
end
it 'implements lease_timeout' do
expect(job.lease_timeout).to be_a(ActiveSupport::Duration)
end
describe '#perform' do
it 'tries to obtain a lease' do
expect_to_obtain_exclusive_lease(job.lease_key)
job.perform
end
end
end
# Example usage:
#
# it_behaves_like 'it is rate limited to 1 call per', 5.seconds do
# subject { described_class.new }
# let(:rate_limited_method) { subject.perform }
# end
#
shared_examples_for 'it is rate limited to 1 call per' do |minimum_duration|
before do
# Allow Timecop freeze and travel without the block form
Timecop.safe_mode = false
Timecop.freeze
time_travel_during_rate_limited_method(actual_duration)
end
after do
Timecop.return
Timecop.safe_mode = true
end
context 'when the work finishes in 0 seconds' do
let(:actual_duration) { 0 }
it 'sleeps exactly the minimum duration' do
expect(subject).to receive(:sleep).with(a_value_within(0.01).of(minimum_duration))
rate_limited_method
end
end
context 'when the work finishes in 10% of minimum duration' do
let(:actual_duration) { 0.1 * minimum_duration }
it 'sleeps 90% of minimum duration' do
expect(subject).to receive(:sleep).with(a_value_within(0.01).of(0.9 * minimum_duration))
rate_limited_method
end
end
context 'when the work finishes in 90% of minimum duration' do
let(:actual_duration) { 0.9 * minimum_duration }
it 'sleeps 10% of minimum duration' do
expect(subject).to receive(:sleep).with(a_value_within(0.01).of(0.1 * minimum_duration))
rate_limited_method
end
end
context 'when the work finishes exactly at minimum duration' do
let(:actual_duration) { minimum_duration }
it 'does not sleep' do
expect(subject).not_to receive(:sleep)
rate_limited_method
end
end
context 'when the work takes 10% longer than minimum duration' do
let(:actual_duration) { 1.1 * minimum_duration }
it 'does not sleep' do
expect(subject).not_to receive(:sleep)
rate_limited_method
end
end
context 'when the work takes twice as long as minimum duration' do
let(:actual_duration) { 2 * minimum_duration }
it 'does not sleep' do
expect(subject).not_to receive(:sleep)
rate_limited_method
end
end
def time_travel_during_rate_limited_method(actual_duration)
# Save the original implementation of ensure_minimum_duration
original_ensure_minimum_duration = subject.method(:ensure_minimum_duration)
allow(subject).to receive(:ensure_minimum_duration) do |minimum_duration, &block|
original_ensure_minimum_duration.call(minimum_duration) do
# Time travel inside the block inside ensure_minimum_duration
Timecop.travel(actual_duration) if actual_duration && actual_duration > 0
end
end
end
end

View file

@ -0,0 +1,179 @@
# frozen_string_literal: true
require 'spec_helper'
describe Reenqueuer do
include ExclusiveLeaseHelpers
let_it_be(:worker_class) do
Class.new do
def self.name
'Gitlab::Foo::Bar::DummyWorker'
end
include ApplicationWorker
prepend Reenqueuer
attr_reader :performed_args
def perform(*args)
@performed_args = args
success? # for stubbing
end
def success?
false
end
def lease_timeout
30.seconds
end
end
end
subject(:job) { worker_class.new }
before do
allow(job).to receive(:sleep) # faster tests
end
it_behaves_like 'reenqueuer'
it_behaves_like 'it is rate limited to 1 call per', 5.seconds do
let(:rate_limited_method) { subject.perform }
end
it 'disables Sidekiq retries' do
expect(job.sidekiq_options_hash).to include('retry' => false)
end
describe '#perform', :clean_gitlab_redis_shared_state do
let(:arbitrary_args) { [:foo, 'bar', { a: 1 }] }
context 'when the lease is available' do
it 'does perform' do
job.perform(*arbitrary_args)
expect(job.performed_args).to eq(arbitrary_args)
end
end
context 'when the lease is taken' do
before do
stub_exclusive_lease_taken(job.lease_key)
end
it 'does not perform' do
job.perform(*arbitrary_args)
expect(job.performed_args).to be_nil
end
end
context 'when #perform returns truthy' do
before do
allow(job).to receive(:success?).and_return(true)
end
it 'reenqueues the worker' do
expect(worker_class).to receive(:perform_async)
job.perform
end
end
context 'when #perform returns falsey' do
it 'does not reenqueue the worker' do
expect(worker_class).not_to receive(:perform_async)
job.perform
end
end
end
end
describe Reenqueuer::ReenqueuerSleeper do
let_it_be(:dummy_class) do
Class.new do
include Reenqueuer::ReenqueuerSleeper
def rate_limited_method
ensure_minimum_duration(11.seconds) do
# do work
end
end
end
end
subject(:dummy) { dummy_class.new }
# Test that rate_limited_method is rate limited by ensure_minimum_duration
it_behaves_like 'it is rate limited to 1 call per', 11.seconds do
let(:rate_limited_method) { dummy.rate_limited_method }
end
# Test ensure_minimum_duration more directly
describe '#ensure_minimum_duration' do
around do |example|
# Allow Timecop.travel without the block form
Timecop.safe_mode = false
Timecop.freeze do
example.run
end
Timecop.safe_mode = true
end
let(:minimum_duration) { 4.seconds }
context 'when the block completes well before the minimum duration' do
let(:time_left) { 3.seconds }
it 'sleeps until the minimum duration' do
expect(dummy).to receive(:sleep).with(a_value_within(0.01).of(time_left))
dummy.ensure_minimum_duration(minimum_duration) do
Timecop.travel(minimum_duration - time_left)
end
end
end
context 'when the block completes just before the minimum duration' do
let(:time_left) { 0.1.seconds }
it 'sleeps until the minimum duration' do
expect(dummy).to receive(:sleep).with(a_value_within(0.01).of(time_left))
dummy.ensure_minimum_duration(minimum_duration) do
Timecop.travel(minimum_duration - time_left)
end
end
end
context 'when the block completes just after the minimum duration' do
let(:time_over) { 0.1.seconds }
it 'does not sleep' do
expect(dummy).not_to receive(:sleep)
dummy.ensure_minimum_duration(minimum_duration) do
Timecop.travel(minimum_duration + time_over)
end
end
end
context 'when the block completes well after the minimum duration' do
let(:time_over) { 10.seconds }
it 'does not sleep' do
expect(dummy).not_to receive(:sleep)
dummy.ensure_minimum_duration(minimum_duration) do
Timecop.travel(minimum_duration + time_over)
end
end
end
end
end