diff options
65 files changed, 1711 insertions, 342 deletions
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 428dfe5fcf7..3096ccad0aa 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,22 +1,23 @@ <script> /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ -import { format } from 'timeago.js'; import _ from 'underscore'; 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 CommitComponent from '~/vue_shared/components/commit.vue'; import Icon from '~/vue_shared/components/icon.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 ExternalUrlComponent from './environment_external_url.vue'; -import StopComponent from './environment_stop.vue'; +import MonitoringButtonComponent from './environment_monitoring.vue'; +import PinComponent from './environment_pin.vue'; import RollbackComponent from './environment_rollback.vue'; +import StopComponent from './environment_stop.vue'; import TerminalButtonComponent from './environment_terminal_button.vue'; -import MonitoringButtonComponent from './environment_monitoring.vue'; -import CommitComponent from '../../vue_shared/components/commit.vue'; -import eventHub from '../event_hub'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; /** * Environment Item Component @@ -26,21 +27,22 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; export default { components: { - CommitComponent, - Icon, ActionsComponent, + CommitComponent, ExternalUrlComponent, - StopComponent, + Icon, + MonitoringButtonComponent, + PinComponent, RollbackComponent, + StopComponent, TerminalButtonComponent, - MonitoringButtonComponent, TooltipOnTruncate, UserAvatarLink, }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [environmentItemMixin], + mixins: [environmentItemMixin, timeagoMixin], props: { canReadEnvironment: { @@ -52,7 +54,12 @@ export default { model: { type: Object, required: true, - default: () => ({}), + }, + + shouldShowAutoStopDate: { + type: Boolean, + required: false, + default: false, }, tableData: { @@ -77,6 +84,16 @@ export default { }, /** + * Checkes whether the row displayed is a folder. + * + * @returns {Boolean} + */ + + isFolder() { + return this.model.isFolder; + }, + + /** * Checkes whether the environment is protected. * (`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} */ - canShowDate() { + canShowDeploymentDate() { return this.model && this.model.last_deployment && this.model.last_deployment.deployed_at; }, /** - * Human readable date. + * Human readable deployment date. * * @returns {String} */ deployedDate() { - if (this.canShowDate) { - return format(this.model.last_deployment.deployed_at); + if (this.canShowDeploymentDate) { + return { + formatted: this.timeFormatted(this.model.last_deployment.deployed_at), + tooltip: this.tooltipTitle(this.model.last_deployment.deployed_at), + }; } - return ''; + return { + formatted: '', + tooltip: '', + }; }, actions() { @@ -345,6 +402,15 @@ export default { }, /** + * 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 * if all the information needed is present * and if the environment is not a folder. @@ -353,7 +419,7 @@ export default { */ shouldRenderBuildName() { return ( - !this.model.isFolder && + !this.isFolder && !_.isEmpty(this.model.last_deployment) && !_.isEmpty(this.model.last_deployment.deployable) ); @@ -383,11 +449,7 @@ export default { * @return {String} */ externalURL() { - if (this.model && this.model.external_url) { - return this.model.external_url; - } - - return ''; + return this.model.external_url || ''; }, /** @@ -399,26 +461,22 @@ export default { */ shouldRenderDeploymentID() { return ( - !this.model.isFolder && + !this.isFolder && !_.isEmpty(this.model.last_deployment) && this.model.last_deployment.iid !== undefined ); }, environmentPath() { - if (this.model && this.model.environment_path) { - return this.model.environment_path; - } - - return ''; + return this.model.environment_path || ''; }, 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() { @@ -447,7 +505,7 @@ export default { <div :class="{ 'js-child-row environment-child-row': model.isChildren, - 'folder-row': model.isFolder, + 'folder-row': isFolder, }" class="gl-responsive-table-row" role="row" @@ -457,7 +515,7 @@ export default { :class="tableData.name.spacing" 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 }} </div> @@ -466,7 +524,7 @@ export default { </span> <span - v-if="!model.isFolder" + v-if="!isFolder" v-gl-tooltip :title="model.name" class="environment-name table-mobile-content" @@ -506,7 +564,7 @@ export default { {{ deploymentInternalId }} </span> - <span v-if="!model.isFolder && deploymentHasUser" class="text-break-word"> + <span v-if="!isFolder && deploymentHasUser" class="text-break-word"> by <user-avatar-link :link-href="deploymentUser.web_url" @@ -516,6 +574,10 @@ export default { class="js-deploy-user-container float-none" /> </span> + + <div v-if="showNoDeployments" class="commit-title table-mobile-content"> + {{ s__('Environments|No deployments yet') }} + </div> </div> <div @@ -536,14 +598,8 @@ export default { </a> </div> - <div - v-if="!model.isFolder" - class="table-section" - :class="tableData.commit.spacing" - role="gridcell" - > + <div v-if="!isFolder" class="table-section" :class="tableData.commit.spacing" role="gridcell"> <div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div> - <div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content"> <commit-component :tag="commitTag" @@ -554,31 +610,51 @@ export default { :author="commitAuthor" /> </div> - <div v-if="!hasLastDeploymentKey" class="commit-title table-mobile-content"> - {{ s__('Environments|No deployments yet') }} - </div> + </div> + + <div v-if="!isFolder" class="table-section" :class="tableData.date.spacing" role="gridcell"> + <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div> + <span + v-if="canShowDeploymentDate" + 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> </div> <div - v-if="!model.isFolder" + v-if="!isFolder && shouldShowAutoStopDate" class="table-section" - :class="tableData.date.spacing" + :class="tableData.autoStop.spacing" role="gridcell" > - <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div> - - <span v-if="canShowDate" class="environment-created-date-timeago table-mobile-content"> - {{ deployedDate }} + <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="!model.isFolder && displayEnvironmentActions" + v-if="!isFolder && displayEnvironmentActions" class="table-section table-button-footer" :class="tableData.actions.spacing" role="gridcell" > <div class="btn-group table-action-buttons" role="group"> + <pin-component + v-if="canShowAutoStopDate && shouldShowAutoStopDate" + :auto-stop-url="autoStopUrl" + /> + <external-url-component v-if="externalURL && canReadEnvironment" :external-url="externalURL" diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue new file mode 100644 index 00000000000..7908928a7ac --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_pin.vue @@ -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> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 453e7610e21..30299ccc7bc 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -6,6 +6,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import _ from 'underscore'; import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin'; import { s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import EnvironmentItem from './environment_item.vue'; export default { @@ -16,7 +17,7 @@ export default { CanaryDeploymentCallout: () => import('ee_component/environments/components/canary_deployment_callout.vue'), }, - mixins: [environmentTableMixin], + mixins: [environmentTableMixin, glFeatureFlagsMixin()], props: { environments: { type: Array, @@ -42,6 +43,9 @@ export default { : env, ); }, + shouldShowAutoStopDate() { + return this.glFeatures.autoStopEnvironments; + }, tableData() { return { // percent spacing for cols, should add up to 100 @@ -65,8 +69,12 @@ export default { title: s__('Environments|Updated'), spacing: 'section-10', }, + autoStop: { + title: s__('Environments|Auto stop in'), + spacing: 'section-5', + }, 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"> {{ tableData.date.title }} </div> + <div + v-if="shouldShowAutoStopDate" + class="table-section" + :class="tableData.autoStop.spacing" + role="columnheader" + > + {{ tableData.autoStop.title }} + </div> </div> <template v-for="(model, i) in sortedEnvironments" :model="model"> <div @@ -130,6 +146,7 @@ export default { :key="`environment-item-${i}`" :model="model" :can-read-environment="canReadEnvironment" + :should-show-auto-stop-date="shouldShowAutoStopDate" :table-data="tableData" /> diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 31347d95a25..34374e306a4 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -90,16 +90,19 @@ export default { 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) { this.isLoading = true; this.service .postAction(endpoint) .then(() => this.fetchEnvironments()) - .catch(() => { + .catch(err => { 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 }); }, + + 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: { @@ -199,6 +209,8 @@ export default { eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal); eventHub.$on('rollbackEnvironment', this.rollbackEnvironment); + + eventHub.$on('cancelAutoStop', this.cancelAutoStop); }, beforeDestroy() { @@ -208,5 +220,7 @@ export default { eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal); eventHub.$off('rollbackEnvironment', this.rollbackEnvironment); + + eventHub.$off('cancelAutoStop', this.cancelAutoStop); }, }; diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb index c473023cacb..f409193aefc 100644 --- a/app/controllers/profiles/active_sessions_controller.rb +++ b/app/controllers/profiles/active_sessions_controller.rb @@ -4,4 +4,13 @@ class Profiles::ActiveSessionsController < Profiles::ApplicationController def index @sessions = ActiveSession.list(current_user).reject(&:is_impersonated) 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 diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 1179782036d..953a6d5b18a 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -15,6 +15,9 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do push_frontend_feature_flag(:prometheus_computed_alerts) end + before_action do + push_frontend_feature_flag(:auto_stop_environments) + end after_action :expire_etag_cache, only: [:cancel_auto_stop] def index diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb index 5a0d53d9683..48da44123f6 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -17,7 +17,7 @@ class PipelinesFinder return Ci::Pipeline.none end - items = pipelines + items = pipelines.no_child items = by_scope(items) items = by_status(items) items = by_ref(items) diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 3ecc3137157..f37da1b7f59 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -6,9 +6,11 @@ class ActiveSession SESSION_BATCH_SIZE = 200 ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100 + attr_writer :session_id + attr_accessor :created_at, :updated_at, - :session_id, :ip_address, - :browser, :os, :device_name, :device_type, + :ip_address, :browser, :os, + :device_name, :device_type, :is_impersonated def current?(session) @@ -21,6 +23,11 @@ class ActiveSession device_type&.titleize end + def public_id + encrypted_id = Gitlab::CryptoHelper.aes256_gcm_encrypt(session_id) + CGI.escape(encrypted_id) + end + def self.set(user, request) Gitlab::Redis::SharedState.with do |redis| session_id = request.session.id @@ -70,6 +77,11 @@ class ActiveSession 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) 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}" } @@ -146,9 +158,9 @@ class ActiveSession # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS. sessions = active_session_entries(session_ids, user.id, redis) sessions.sort_by! {|session| session.updated_at }.reverse! - sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS) - sessions = sessions.map { |session| session.session_id } - destroy_sessions(redis, user, sessions) if sessions.any? + destroyable_sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS) + destroyable_session_ids = destroyable_sessions.map { |session| session.send :session_id } # rubocop:disable GitlabSecurity/PublicSend + destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any? end def self.cleaned_up_lookup_entries(redis, user) @@ -167,4 +179,15 @@ class ActiveSession entries.compact 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 diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 6c51f650b6a..123b8e75ad5 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -54,6 +54,10 @@ module Ci def to_partial_path 'projects/generic_commit_statuses/generic_commit_status' end + + def yaml_for_downstream + nil + end end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index ab0a4fd6289..c8c1bbacad3 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -61,7 +61,9 @@ module Ci has_one :chat_data, class_name: 'Ci::PipelineChatData' 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 :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 :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline @@ -213,6 +215,7 @@ module Ci end 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 :for_user, -> (user) { where(user: user) } scope :for_sha, -> (sha) { where(sha: sha) } @@ -508,10 +511,6 @@ module Ci builds.skipped.after_stage(stage_idx).find_each(&:process) end - def child? - false - end - def latest? return false unless git_ref && commit.present? @@ -694,6 +693,24 @@ module Ci all_merge_requests.order(id: :desc) 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) Gitlab::Ci::Status::Pipeline::Factory .new(self, current_user) diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index 3cd88807969..24a26cb055c 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -23,10 +23,11 @@ module Ci schedule: 4, api: 5, external: 6, - pipeline: 7, + cross_project_pipeline: 7, chat: 8, merge_request_event: 10, - external_pull_request_event: 11 + external_pull_request_event: 11, + parent_pipeline: 12 } end @@ -38,7 +39,8 @@ module Ci repository_source: 1, auto_devops_source: 2, remote_source: 4, - external_project_source: 5 + external_project_source: 5, + bridge_source: 6 } end diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb index feaec27281c..d71e3b55b9a 100644 --- a/app/models/ci/sources/pipeline.rb +++ b/app/models/ci/sources/pipeline.rb @@ -18,6 +18,8 @@ module Ci validates :source_project, presence: true validates :source_job, presence: true validates :source_pipeline, presence: true + + scope :same_project, -> { where(arel_table[:source_project_id].eq(arel_table[:project_id])) } end end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 9e3fba139e3..fe0fad4b9d5 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -13,6 +13,7 @@ module Issuable include CacheMarkdownField include Participable include Mentionable + include Milestoneable include Subscribable include StripAttribute include Awardable @@ -56,7 +57,6 @@ module Issuable belongs_to :author, class_name: 'User' belongs_to :updated_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 def authors_loaded? @@ -89,18 +89,12 @@ module Issuable # to avoid breaking the existing Issuables which may have their descriptions longer validates :description, length: { maximum: DESCRIPTION_LENGTH_MAX }, allow_blank: true, on: :create validate :description_max_length_for_new_records_is_valid, on: :update - validate :milestone_is_valid before_validation :truncate_description_on_import! scope :authored, ->(user) { where(author_id: user) } scope :recent, -> { reorder(id: :desc) } 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 :only_opened, -> { with_state(:opened) } scope :closed, -> { with_state(:closed) } @@ -118,20 +112,6 @@ module Issuable end # 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 :any_label, -> { joins(:label_links).group(:id) } scope :join_project, -> { joins(:project) } @@ -164,10 +144,6 @@ module Issuable 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 if new_record? && description.length > Issuable::DESCRIPTION_LENGTH_MAX errors.add(:description, :too_long, count: Issuable::DESCRIPTION_LENGTH_MAX) @@ -332,10 +308,6 @@ module Issuable project end - def milestone_available? - project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group) - end - def assignee_or_author?(user) author_id == user.id || assignees.exists?(user.id) end @@ -482,13 +454,6 @@ module Issuable def wipless_title_changed(old_title) old_title != title end - - ## - # Overridden on EE module - # - def supports_milestone? - respond_to?(:milestone_id) - end end Issuable.prepend_if_ee('EE::Issuable') # rubocop: disable Cop/InjectEnterpriseEditionModule diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb new file mode 100644 index 00000000000..7fb3f95bf0a --- /dev/null +++ b/app/models/concerns/milestoneable.rb @@ -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') diff --git a/app/models/issue.rb b/app/models/issue.rb index 88df3baa809..da6450c6092 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -33,6 +33,9 @@ class Issue < ApplicationRecord 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 :merge_requests_closing_issues, diff --git a/app/models/issue_milestone.rb b/app/models/issue_milestone.rb new file mode 100644 index 00000000000..da030077d87 --- /dev/null +++ b/app/models/issue_milestone.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class IssueMilestone < ApplicationRecord + belongs_to :milestone + belongs_to :issue +end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index cdb6205cd51..4eb9c8706d3 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -35,6 +35,9 @@ class MergeRequest < ApplicationRecord has_many :merge_request_diffs + has_many :merge_request_milestones + has_many :milestones, through: :merge_request_milestones + has_one :merge_request_diff, -> { order('merge_request_diffs.id DESC') }, inverse_of: :merge_request diff --git a/app/models/merge_request_milestone.rb b/app/models/merge_request_milestone.rb new file mode 100644 index 00000000000..4fa1d1dcb33 --- /dev/null +++ b/app/models/merge_request_milestone.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class MergeRequestMilestone < ApplicationRecord + belongs_to :milestone + belongs_to :merge_request +end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 987373aaf1b..920c28aeceb 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -38,6 +38,9 @@ class Milestone < ApplicationRecord has_many :merge_requests 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_groups, ->(ids) { where(group_id: ids) } scope :active, -> { with_state(:active) } diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb index 71589ac8315..a4ab1d399bc 100644 --- a/app/serializers/pipeline_details_entity.rb +++ b/app/serializers/pipeline_details_entity.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PipelineDetailsEntity < PipelineEntity + expose :project, using: ProjectEntity + expose :flags do expose :latest?, as: :latest end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index b25a1ea9209..be535a5d414 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -41,6 +41,7 @@ class PipelineSerializer < BaseSerializer def preloaded_relations [ :latest_statuses_ordered_by_stage, + :project, :stages, { failed_builds: %i(project metadata) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index ce3a9eb0772..2daf3a51235 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -23,7 +23,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::Limit::JobActivity].freeze # 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 command = Gitlab::Ci::Pipeline::Chain::Command.new( @@ -46,6 +46,7 @@ module Ci current_user: current_user, push_options: params[:push_options] || {}, chat_data: params[:chat_data], + bridge: bridge, **extra_options(options)) sequence = Gitlab::Ci::Pipeline::Chain::Sequence @@ -104,14 +105,14 @@ module Ci if Feature.enabled?(:ci_support_interruptible_pipelines, project, default_enabled: true) project.ci_pipelines .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)) .alive_or_scheduled .with_only_interruptible_builds else project.ci_pipelines .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)) .created_or_pending end diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb index 37b9b4c362c..d00d46b85f2 100644 --- a/app/services/ci/pipeline_trigger_service.rb +++ b/app/services/ci/pipeline_trigger_service.rb @@ -44,7 +44,7 @@ module Ci return error("400 Job has to be running", 400) unless job.running? 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_pipeline: job.pipeline, source_project: job.project, diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml index bb31049111c..f3ad0c4c8ad 100644 --- a/app/views/profiles/active_sessions/_active_session.html.haml +++ b/app/views/profiles/active_sessions/_active_session.html.haml @@ -24,3 +24,9 @@ %strong= _('Signed in') = s_('ProfileSession|on') = 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') diff --git a/app/views/projects/environments/_pin_button.html.haml b/app/views/projects/environments/_pin_button.html.haml new file mode 100644 index 00000000000..5c7bfc2b17b --- /dev/null +++ b/app/views/projects/environments/_pin_button.html.haml @@ -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') diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 62b1c140794..ff78abfddf4 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -32,9 +32,14 @@ = button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do = s_('Environments|Stop environment') -.top-area - %h3.page-title= @environment.name - .nav-controls.ml-auto.my-2 +.top-area.justify-content-between + .d-flex + %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/external_url', environment: @environment = render 'projects/environments/metrics_button', environment: @environment diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index 2a2ccf8a6de..93a43b5d1ea 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -4,6 +4,9 @@ %h4.sub-header = _("Programming languages used in this repository") + %p + = _("Measured in bytes of code. Excludes generated and vendored code.") + .row .col-md-4 %ul.bordered-list diff --git a/app/workers/concerns/reenqueuer.rb b/app/workers/concerns/reenqueuer.rb new file mode 100644 index 00000000000..5cc13e490d8 --- /dev/null +++ b/app/workers/concerns/reenqueuer.rb @@ -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 diff --git a/changelogs/unreleased/20956-autostop-frontend.yml b/changelogs/unreleased/20956-autostop-frontend.yml new file mode 100644 index 00000000000..e31f1033c7a --- /dev/null +++ b/changelogs/unreleased/20956-autostop-frontend.yml @@ -0,0 +1,5 @@ +--- +title: Auto stop environments after a certain period +merge_request: 20372 +author: +type: added diff --git a/changelogs/unreleased/27518-revoke-active-sessions.yml b/changelogs/unreleased/27518-revoke-active-sessions.yml new file mode 100644 index 00000000000..e9fc26c8821 --- /dev/null +++ b/changelogs/unreleased/27518-revoke-active-sessions.yml @@ -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 diff --git a/changelogs/unreleased/36032-multiple-milestone-storage.yml b/changelogs/unreleased/36032-multiple-milestone-storage.yml new file mode 100644 index 00000000000..ac373b4442a --- /dev/null +++ b/changelogs/unreleased/36032-multiple-milestone-storage.yml @@ -0,0 +1,5 @@ +--- +title: Setup storage for multiple milestones +merge_request: 22043 +author: +type: added diff --git a/changelogs/unreleased/create-downstream-pipeline-in-same-project.yml b/changelogs/unreleased/create-downstream-pipeline-in-same-project.yml new file mode 100644 index 00000000000..268d471eb0d --- /dev/null +++ b/changelogs/unreleased/create-downstream-pipeline-in-same-project.yml @@ -0,0 +1,5 @@ +--- +title: Allow an upstream pipeline to create a downstream pipeline in the same project +merge_request: 20930 +author: +type: added diff --git a/changelogs/unreleased/djensen-explain-programming-languages-chart.yml b/changelogs/unreleased/djensen-explain-programming-languages-chart.yml new file mode 100644 index 00000000000..68883fc6d6f --- /dev/null +++ b/changelogs/unreleased/djensen-explain-programming-languages-chart.yml @@ -0,0 +1,5 @@ +--- +title: Add measurement details for programming languages graph +merge_request: 20592 +author: +type: changed diff --git a/changelogs/unreleased/notes_api_system_filter.yml b/changelogs/unreleased/notes_api_system_filter.yml new file mode 100644 index 00000000000..f81be1dcb1c --- /dev/null +++ b/changelogs/unreleased/notes_api_system_filter.yml @@ -0,0 +1,5 @@ +--- +title: 25968-activity-filter-to-notes-api +merge_request: 21159 +author: jhenkens +type: added diff --git a/db/migrate/20191205212923_support_multiple_milestones_for_issues.rb b/db/migrate/20191205212923_support_multiple_milestones_for_issues.rb new file mode 100644 index 00000000000..e0edd76c4b9 --- /dev/null +++ b/db/migrate/20191205212923_support_multiple_milestones_for_issues.rb @@ -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 diff --git a/db/migrate/20191205212924_support_multiple_milestones_for_merge_requests.rb b/db/migrate/20191205212924_support_multiple_milestones_for_merge_requests.rb new file mode 100644 index 00000000000..85ad1a748e9 --- /dev/null +++ b/db/migrate/20191205212924_support_multiple_milestones_for_merge_requests.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 5a877870e6c..567e135fdff 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2099,6 +2099,14 @@ ActiveRecord::Schema.define(version: 2019_12_29_140154) do t.index ["issue_id"], name: "index_issue_metrics" 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| t.integer "service_id", 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" 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| t.integer "merge_request_id", null: false 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: "target_id", name: "fk_e71bb44f1f", 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_user_mentions", "issues", 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", "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_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", "notes", on_delete: :cascade add_foreign_key "merge_requests", "ci_pipelines", column: "head_pipeline_id", name: "fk_fd82eae0b9", on_delete: :nullify diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 62a113a407c..c0b0590716d 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -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. 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. ### `include` diff --git a/doc/user/profile/active_sessions.md b/doc/user/profile/active_sessions.md index f68b11a57ec..11e5ef293e4 100644 --- a/doc/user/profile/active_sessions.md +++ b/doc/user/profile/active_sessions.md @@ -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 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 Include any troubleshooting steps that you can foresee. If you know beforehand what issues diff --git a/doc/user/profile/img/active_sessions_list.png b/doc/user/profile/img/active_sessions_list.png Binary files differindex 41173c7eee5..5d94dca69cc 100644 --- a/doc/user/profile/img/active_sessions_list.png +++ b/doc/user/profile/img/active_sessions_list.png diff --git a/lib/api/notes.rb b/lib/api/notes.rb index 89e4da5a42e..9575e8e9f36 100644 --- a/lib/api/notes.rb +++ b/lib/api/notes.rb @@ -24,6 +24,8 @@ module API desc: 'Return notes ordered by `created_at` or `updated_at` fields.' optional :sort, type: String, values: %w[asc desc], default: 'desc', 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 end # rubocop: disable CodeReuse/ActiveRecord @@ -35,7 +37,8 @@ module API # at the DB query level (which we cannot in that case), the current # page can have less elements than :per_page even if # 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 # mismatch between the pagination headers info and the actual notes diff --git a/lib/gitlab/ci/pipeline/chain/command.rb b/lib/gitlab/ci/pipeline/chain/command.rb index c2df419cca0..0f355906948 100644 --- a/lib/gitlab/ci/pipeline/chain/command.rb +++ b/lib/gitlab/ci/pipeline/chain/command.rb @@ -10,7 +10,7 @@ module Gitlab :trigger_request, :schedule, :merge_request, :external_pull_request, :ignore_skip_ci, :save_incompleted, :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: :config_content, :config_processor, :stage_seeds ) do diff --git a/lib/gitlab/ci/pipeline/chain/config/content.rb b/lib/gitlab/ci/pipeline/chain/config/content.rb index f9fffbcb517..66bead3a416 100644 --- a/lib/gitlab/ci/pipeline/chain/config/content.rb +++ b/lib/gitlab/ci/pipeline/chain/config/content.rb @@ -9,7 +9,7 @@ module Gitlab include Chain::Helpers 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::ExternalProject, Gitlab::Ci::Pipeline::Chain::Config::Content::Remote, @@ -17,7 +17,7 @@ module Gitlab ].freeze 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::LegacyAutoDevops ].freeze diff --git a/lib/gitlab/ci/pipeline/chain/config/content/bridge.rb b/lib/gitlab/ci/pipeline/chain/config/content/bridge.rb new file mode 100644 index 00000000000..39ffa2d4e25 --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/config/content/bridge.rb @@ -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 diff --git a/lib/gitlab/ci/pipeline/chain/config/content/runtime.rb b/lib/gitlab/ci/pipeline/chain/config/content/runtime.rb deleted file mode 100644 index 4811d3d913d..00000000000 --- a/lib/gitlab/ci/pipeline/chain/config/content/runtime.rb +++ /dev/null @@ -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 diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 4f4b4c02eb9..ffd242f386f 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -24,6 +24,8 @@ tree: - milestone: - events: - :push_event_payload + - issue_milestones: + - :milestone - resource_label_events: - label: - :priorities @@ -57,6 +59,8 @@ tree: - milestone: - events: - :push_event_payload + - merge_request_milestones: + - :milestone - resource_label_events: - label: - :priorities @@ -202,6 +206,12 @@ excluded_attributes: - :latest_merge_request_diff_id - :head_pipeline_id - :state_id + issue_milestones: + - :milestone_id + - :issue_id + merge_request_milestones: + - :milestone_id + - :merge_request_id award_emoji: - :awardable_id statuses: diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b573205a85f..7dff8495b06 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2152,6 +2152,9 @@ msgstr "" msgid "Are you sure? Removing this GPG key does not affect already signed commits." 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." 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." msgstr "" +msgid "Environments|An error occurred while canceling the auto stop, please try again" +msgstr "" + msgid "Environments|An error occurred while fetching the environments." msgstr "" @@ -6834,6 +6840,12 @@ msgstr "" msgid "Environments|Are you sure you want to stop this environment?" msgstr "" +msgid "Environments|Auto stop in" +msgstr "" + +msgid "Environments|Auto stops %{auto_stop_time}" +msgstr "" + msgid "Environments|Commit" msgstr "" @@ -11121,6 +11133,9 @@ msgstr "" msgid "May" msgstr "" +msgid "Measured in bytes of code. Excludes generated and vendored code." +msgstr "" + msgid "Median" msgstr "" @@ -13329,6 +13344,9 @@ msgstr "" msgid "Prevent approval of merge requests by merge request committers" msgstr "" +msgid "Prevent environment from auto-stopping" +msgstr "" + msgid "Preview" msgstr "" diff --git a/public/robots.txt b/public/robots.txt index 2cda837d6f1..f2ddb384ebb 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -72,3 +72,4 @@ Disallow: /*/*/protected_branches Disallow: /*/*/uploads/ Disallow: /*/-/group_members Disallow: /*/project_members +Disallow: /groups/*/-/analytics diff --git a/spec/features/profiles/active_sessions_spec.rb b/spec/features/profiles/active_sessions_spec.rb index a5c2d15f598..bab6251a5d4 100644 --- a/spec/features/profiles/active_sessions_spec.rb +++ b/spec/features/profiles/active_sessions_spec.rb @@ -84,4 +84,31 @@ describe 'Profile > Active Sessions', :clean_gitlab_redis_shared_state do expect(page).not_to have_content('Chrome on Windows') 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 diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 55c6aed19e0..bbd33225bb9 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -12,6 +12,10 @@ describe 'Environment' do project.add_role(user, role) end + def auto_stop_button_selector + %q{button[title="Prevent environment from auto-stopping"]} + end + describe 'environment details page' do let!(:environment) { create(:environment, project: project) } let!(:permissions) { } @@ -27,6 +31,40 @@ describe 'Environment' do expect(page).to have_content(environment.name) 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 it 'does not show deployments' do expect(page).to have_content('You don\'t have any deployments right now.') diff --git a/spec/finders/pipelines_finder_spec.rb b/spec/finders/pipelines_finder_spec.rb index c8a4ea799c3..1dbf9491118 100644 --- a/spec/finders/pipelines_finder_spec.rb +++ b/spec/finders/pipelines_finder_spec.rb @@ -64,6 +64,19 @@ describe PipelinesFinder do 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| context "when status is #{target}" do let(:params) { { status: target } } diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index 52625c64a1c..004687fcf44 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -1,6 +1,8 @@ import { mount } from '@vue/test-utils'; import { format } from 'timeago.js'; import EnvironmentItem from '~/environments/components/environment_item.vue'; +import PinComponent from '~/environments/components/environment_pin.vue'; + import { environment, folder, tableData } from './mock_data'; describe('Environment item', () => { @@ -26,6 +28,8 @@ describe('Environment item', () => { }); }); + const findAutoStop = () => wrapper.find('.js-auto-stop'); + afterEach(() => { wrapper.destroy(); }); @@ -77,6 +81,79 @@ describe('Environment item', () => { 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', () => { diff --git a/spec/frontend/environments/environment_pin_spec.js b/spec/frontend/environments/environment_pin_spec.js new file mode 100644 index 00000000000..d1d6735fa38 --- /dev/null +++ b/spec/frontend/environments/environment_pin_spec.js @@ -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); + }); +}); diff --git a/spec/frontend/environments/mock_data.js b/spec/frontend/environments/mock_data.js index a014108b898..a2b581578d2 100644 --- a/spec/frontend/environments/mock_data.js +++ b/spec/frontend/environments/mock_data.js @@ -63,6 +63,7 @@ const environment = { log_path: 'root/ci-folders/environments/31/logs', created_at: '2016-11-07T11:11:16.525Z', updated_at: '2016-11-10T15:55:58.778Z', + auto_stop_at: null, }; const folder = { @@ -98,6 +99,10 @@ const tableData = { title: 'Updated', spacing: 'section-10', }, + autoStop: { + title: 'Auto stop in', + spacing: 'section-5', + }, actions: { spacing: 'section-25', }, diff --git a/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb index aaea044595f..4c4359ad5d2 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb @@ -15,6 +15,42 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do stub_feature_flags(ci_root_config_content: false) 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 let(:ci_config_path) { 'path/to/config.yml' } @@ -135,6 +171,23 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do 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 let(:ci_config_path) { 'path/to/config.yml' } let(:config_content_result) do diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 8ddb4c23b81..dc0851294b5 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -6,6 +6,8 @@ issues: - assignees - updated_by - milestone +- issue_milestones +- milestones - notes - resource_label_events - resource_weight_events @@ -78,6 +80,8 @@ milestone: - boards - milestone_releases - releases +- issue_milestones +- merge_request_milestones snippets: - author - project @@ -106,6 +110,8 @@ merge_requests: - assignee - updated_by - milestone +- merge_request_milestones +- milestones - notes - resource_label_events - label_links @@ -146,6 +152,12 @@ merge_requests: - deployment_merge_requests - deployments - user_mentions +issue_milestones: +- milestone +- issue +merge_request_milestones: +- milestone +- merge_request external_pull_requests: - project merge_request_diff: @@ -189,6 +201,8 @@ ci_pipelines: - sourced_pipelines - triggered_by_pipeline - triggered_pipelines +- child_pipelines +- parent_pipeline - downstream_bridges - job_artifacts - vulnerabilities_occurrence_pipelines diff --git a/spec/models/active_session_spec.rb b/spec/models/active_session_spec.rb index 6930f743c2f..bff3ac313c4 100644 --- a/spec/models/active_session_spec.rb +++ b/spec/models/active_session_spec.rb @@ -44,6 +44,19 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do 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 it 'returns all sessions by user' do Gitlab::Redis::SharedState.with do |redis| @@ -173,8 +186,7 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do device_name: 'iPhone 6', device_type: 'smartphone', created_at: Time.zone.parse('2018-03-12 09:06'), - updated_at: Time.zone.parse('2018-03-12 09:06'), - session_id: '6919a6f1bb119dd7396fadc38fd18d0d' + updated_at: Time.zone.parse('2018-03-12 09:06') ) end end @@ -244,6 +256,40 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do 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 before do stub_const("ActiveSession::ALLOWED_NUMBER_OF_ACTIVE_SESSIONS", 5) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index b30e88532e1..ce01765bb8c 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -2716,4 +2716,114 @@ describe Ci::Pipeline, :mailer do 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 diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 76a3a825978..2f4855efff0 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -53,43 +53,6 @@ describe Issuable do it_behaves_like 'validates description length with custom validation' it_behaves_like 'truncates the description to its allowed maximum length on import' 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 describe "Scope" do @@ -141,48 +104,6 @@ describe Issuable do 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 let!(:searchable_issue) { create(:issue, title: "Searchable awesome issue") } let!(:searchable_issue2) { create(:issue, title: 'Aw') } @@ -809,27 +730,6 @@ describe Issuable do 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 context "issue description with long path string" do let(:mentionable) { build(:issue, description: "/a" * 50000) } @@ -854,91 +754,4 @@ describe Issuable do it_behaves_like 'matches_cross_reference_regex? fails fast' 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 diff --git a/spec/models/concerns/milestoneable_spec.rb b/spec/models/concerns/milestoneable_spec.rb new file mode 100644 index 00000000000..186bf2c6290 --- /dev/null +++ b/spec/models/concerns/milestoneable_spec.rb @@ -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 diff --git a/spec/models/user_preference_spec.rb b/spec/models/user_preference_spec.rb index e09c91e874a..bb88983e140 100644 --- a/spec/models/user_preference_spec.rb +++ b/spec/models/user_preference_spec.rb @@ -5,6 +5,12 @@ require 'spec_helper' describe UserPreference do 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 let(:issuable) { build_stubbed(:issue) } diff --git a/spec/requests/api/notes_spec.rb b/spec/requests/api/notes_spec.rb index cc2038a7245..b4416344ecf 100644 --- a/spec/requests/api/notes_spec.rb +++ b/spec/requests/api/notes_spec.rb @@ -101,6 +101,75 @@ describe API::Notes do expect(json_response.first['body']).to eq(cross_reference_note.note) 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 describe "GET /projects/:id/noteable/:noteable_id/notes/:note_id" do diff --git a/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb b/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb new file mode 100644 index 00000000000..33cd6e164b0 --- /dev/null +++ b/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb @@ -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 diff --git a/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb new file mode 100644 index 00000000000..7dffbb04fdc --- /dev/null +++ b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb @@ -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 diff --git a/spec/workers/concerns/reenqueuer_spec.rb b/spec/workers/concerns/reenqueuer_spec.rb new file mode 100644 index 00000000000..b28f83d211b --- /dev/null +++ b/spec/workers/concerns/reenqueuer_spec.rb @@ -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 |