summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue190
-rw-r--r--app/assets/javascripts/environments/components/environment_pin.vue37
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue21
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js20
-rw-r--r--app/controllers/profiles/active_sessions_controller.rb9
-rw-r--r--app/controllers/projects/environments_controller.rb3
-rw-r--r--app/finders/pipelines_finder.rb2
-rw-r--r--app/models/active_session.rb33
-rw-r--r--app/models/ci/bridge.rb4
-rw-r--r--app/models/ci/pipeline.rb25
-rw-r--r--app/models/ci/pipeline_enums.rb8
-rw-r--r--app/models/ci/sources/pipeline.rb2
-rw-r--r--app/models/concerns/issuable.rb37
-rw-r--r--app/models/concerns/milestoneable.rb62
-rw-r--r--app/models/issue.rb3
-rw-r--r--app/models/issue_milestone.rb6
-rw-r--r--app/models/merge_request.rb3
-rw-r--r--app/models/merge_request_milestone.rb6
-rw-r--r--app/models/milestone.rb3
-rw-r--r--app/serializers/pipeline_details_entity.rb2
-rw-r--r--app/serializers/pipeline_serializer.rb1
-rw-r--r--app/services/ci/create_pipeline_service.rb7
-rw-r--r--app/services/ci/pipeline_trigger_service.rb2
-rw-r--r--app/views/profiles/active_sessions/_active_session.html.haml6
-rw-r--r--app/views/projects/environments/_pin_button.html.haml3
-rw-r--r--app/views/projects/environments/show.html.haml11
-rw-r--r--app/views/projects/graphs/charts.html.haml3
-rw-r--r--app/workers/concerns/reenqueuer.rb101
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