summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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
-rw-r--r--changelogs/unreleased/20956-autostop-frontend.yml5
-rw-r--r--changelogs/unreleased/27518-revoke-active-sessions.yml6
-rw-r--r--changelogs/unreleased/36032-multiple-milestone-storage.yml5
-rw-r--r--changelogs/unreleased/create-downstream-pipeline-in-same-project.yml5
-rw-r--r--changelogs/unreleased/djensen-explain-programming-languages-chart.yml5
-rw-r--r--changelogs/unreleased/notes_api_system_filter.yml5
-rw-r--r--db/migrate/20191205212923_support_multiple_milestones_for_issues.rb14
-rw-r--r--db/migrate/20191205212924_support_multiple_milestones_for_merge_requests.rb14
-rw-r--r--db/schema.rb20
-rw-r--r--doc/ci/yaml/README.md2
-rw-r--r--doc/user/profile/active_sessions.md5
-rw-r--r--doc/user/profile/img/active_sessions_list.pngbin18633 -> 22266 bytes
-rw-r--r--lib/api/notes.rb5
-rw-r--r--lib/gitlab/ci/pipeline/chain/command.rb2
-rw-r--r--lib/gitlab/ci/pipeline/chain/config/content.rb4
-rw-r--r--lib/gitlab/ci/pipeline/chain/config/content/bridge.rb25
-rw-r--r--lib/gitlab/ci/pipeline/chain/config/content/runtime.rb30
-rw-r--r--lib/gitlab/import_export/import_export.yml10
-rw-r--r--locale/gitlab.pot18
-rw-r--r--public/robots.txt1
-rw-r--r--spec/features/profiles/active_sessions_spec.rb27
-rw-r--r--spec/features/projects/environments/environment_spec.rb38
-rw-r--r--spec/finders/pipelines_finder_spec.rb13
-rw-r--r--spec/frontend/environments/environment_item_spec.js77
-rw-r--r--spec/frontend/environments/environment_pin_spec.js46
-rw-r--r--spec/frontend/environments/mock_data.js5
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb53
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml14
-rw-r--r--spec/models/active_session_spec.rb50
-rw-r--r--spec/models/ci/pipeline_spec.rb110
-rw-r--r--spec/models/concerns/issuable_spec.rb187
-rw-r--r--spec/models/concerns/milestoneable_spec.rb243
-rw-r--r--spec/models/user_preference_spec.rb6
-rw-r--r--spec/requests/api/notes_spec.rb69
-rw-r--r--spec/services/ci/create_pipeline_service/custom_config_content_spec.rb29
-rw-r--r--spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb116
-rw-r--r--spec/workers/concerns/reenqueuer_spec.rb179
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
index 41173c7eee5..5d94dca69cc 100644
--- a/doc/user/profile/img/active_sessions_list.png
+++ b/doc/user/profile/img/active_sessions_list.png
Binary files differ
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