diff options
Diffstat (limited to 'app')
28 files changed, 492 insertions, 118 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 |