summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue57
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue75
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue190
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue83
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js2
-rw-r--r--app/controllers/projects/merge_requests_controller.rb1
-rw-r--r--app/graphql/gitlab_schema.rb2
-rw-r--r--app/services/merge_requests/update_service.rb12
-rw-r--r--changelogs/unreleased/25351-add-buttons.yml5
-rw-r--r--db/migrate/20200305121159_add_merge_request_metrics_first_reassigned_at.rb19
-rw-r--r--db/migrate/20200306095654_add_merge_request_assignee_created_at.rb19
-rw-r--r--db/schema.rb2
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql4
-rw-r--r--doc/api/graphql/reference/index.md2
-rw-r--r--doc/user/project/merge_requests/squash_and_merge.md3
-rw-r--r--lib/gitlab/danger/commit_linter.rb4
-rw-r--r--locale/gitlab.pot33
-rwxr-xr-xscripts/review_apps/automated_cleanup.rb20
-rw-r--r--spec/frontend/notes/helpers.js12
-rw-r--r--spec/frontend/notes/stores/actions_spec.js (renamed from spec/javascripts/notes/stores/actions_spec.js)80
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js124
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js220
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_mock_data.js45
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_spec.js69
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js2
-rw-r--r--spec/javascripts/notes/helpers.js13
-rw-r--r--spec/javascripts/vue_mr_widget/components/deployment_stop_button_spec.js95
-rw-r--r--spec/lib/gitlab/danger/commit_linter_spec.rb33
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml1
31 files changed, 890 insertions, 344 deletions
diff --git a/.gitignore b/.gitignore
index 8dbfaa2e1b1..5dc4571d850 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,7 +55,7 @@ eslint-report.html
/dump.rdb
/jsconfig.json
/log/*.log*
-/node_modules/
+/node_modules
/nohup.out
/public/assets/
/public/uploads.*
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js
index 90741e3aa44..a7ab11290eb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js
@@ -6,3 +6,8 @@ export const RUNNING = 'running';
export const SUCCESS = 'success';
export const FAILED = 'failed';
export const CANCELED = 'canceled';
+
+// ACTION STATUSES
+export const STOPPING = 'stopping';
+export const DEPLOYING = 'deploying';
+export const REDEPLOYING = 'redeploying';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue
index 34866cdfa6f..6f77d2fa779 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue
@@ -1,18 +1,15 @@
<script>
-import { __, s__ } from '~/locale';
+import DeploymentActions from './deployment_actions.vue';
import DeploymentInfo from './deployment_info.vue';
-import DeploymentViewButton from './deployment_view_button.vue';
-import DeploymentStopButton from './deployment_stop_button.vue';
-import { MANUAL_DEPLOY, WILL_DEPLOY, CREATED, RUNNING, SUCCESS } from './constants';
+import { MANUAL_DEPLOY, WILL_DEPLOY, CREATED } from './constants';
export default {
// name: 'Deployment' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
name: 'Deployment',
components: {
+ DeploymentActions,
DeploymentInfo,
- DeploymentStopButton,
- DeploymentViewButton,
},
props: {
deployment: {
@@ -40,38 +37,14 @@ export default {
},
},
computed: {
- appButtonText() {
- return {
- text: this.isCurrent ? s__('Review App|View app') : s__('Review App|View latest app'),
- tooltip: this.isCurrent
- ? ''
- : __('View the latest successful deployment to this environment'),
- };
- },
- canBeManuallyDeployed() {
- return this.computedDeploymentStatus === MANUAL_DEPLOY;
- },
computedDeploymentStatus() {
if (this.deployment.status === CREATED) {
return this.isManual ? MANUAL_DEPLOY : WILL_DEPLOY;
}
return this.deployment.status;
},
- hasExternalUrls() {
- return Boolean(this.deployment.external_url && this.deployment.external_url_formatted);
- },
- isCurrent() {
- return this.computedDeploymentStatus === SUCCESS;
- },
isManual() {
- return Boolean(
- this.deployment.details &&
- this.deployment.details.playable_build &&
- this.deployment.details.playable_build.play_path,
- );
- },
- isDeployInProgress() {
- return this.deployment.status === RUNNING;
+ return Boolean(this.deployment.details?.playable_build?.play_path);
},
},
};
@@ -87,22 +60,12 @@ export default {
:deployment="deployment"
:show-metrics="showMetrics"
/>
- <div>
- <!-- show appropriate version of review app button -->
- <deployment-view-button
- v-if="hasExternalUrls"
- :app-button-text="appButtonText"
- :deployment="deployment"
- :show-visual-review-app="showVisualReviewApp"
- :visual-review-app-metadata="visualReviewAppMeta"
- />
- <!-- if it is stoppable, show stop -->
- <deployment-stop-button
- v-if="deployment.stop_url"
- :is-deploy-in-progress="isDeployInProgress"
- :stop-url="deployment.stop_url"
- />
- </div>
+ <deployment-actions
+ :deployment="deployment"
+ :computed-deployment-status="computedDeploymentStatus"
+ :show-visual-review-app="showVisualReviewApp"
+ :visual-review-app-metadata="visualReviewAppMeta"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
new file mode 100644
index 00000000000..45798fbc9dc
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
@@ -0,0 +1,75 @@
+<script>
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { RUNNING } from './constants';
+
+export default {
+ name: 'DeploymentActionButton',
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ actionsConfiguration: {
+ type: Object,
+ required: true,
+ },
+ actionInProgress: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ buttonTitle: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ computedDeploymentStatus: {
+ type: String,
+ required: true,
+ },
+ containerClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ isActionInProgress() {
+ return Boolean(this.computedDeploymentStatus === RUNNING || this.actionInProgress);
+ },
+ actionInProgressTooltip() {
+ switch (this.actionInProgress) {
+ case this.actionsConfiguration.actionName:
+ return this.actionsConfiguration.busyText;
+ case null:
+ return '';
+ default:
+ return __('Another action is currently in progress');
+ }
+ },
+ isLoading() {
+ return this.actionInProgress === this.actionsConfiguration.actionName;
+ },
+ },
+};
+</script>
+
+<template>
+ <span v-gl-tooltip :title="actionInProgressTooltip" class="d-inline-block" tabindex="0">
+ <gl-button
+ v-gl-tooltip
+ :title="buttonTitle"
+ :loading="isLoading"
+ :disabled="isActionInProgress"
+ :class="`btn btn-default btn-sm inline prepend-left-4 ${containerClasses}`"
+ @click="$emit('click')"
+ >
+ <span class="d-inline-flex align-items-baseline">
+ <slot> </slot>
+ </span>
+ </gl-button>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
new file mode 100644
index 00000000000..a11e62b048a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
@@ -0,0 +1,190 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import createFlash from '~/flash';
+import { visitUrl } from '~/lib/utils/url_utility';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import MRWidgetService from '../../services/mr_widget_service';
+import DeploymentActionButton from './deployment_action_button.vue';
+import DeploymentViewButton from './deployment_view_button.vue';
+import { MANUAL_DEPLOY, FAILED, SUCCESS, STOPPING, DEPLOYING, REDEPLOYING } from './constants';
+
+export default {
+ name: 'DeploymentActions',
+ components: {
+ DeploymentActionButton,
+ DeploymentViewButton,
+ GlIcon,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ computedDeploymentStatus: {
+ type: String,
+ required: true,
+ },
+ deployment: {
+ type: Object,
+ required: true,
+ },
+ showVisualReviewApp: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ visualReviewAppMeta: {
+ type: Object,
+ required: false,
+ default: () => ({
+ sourceProjectId: '',
+ sourceProjectPath: '',
+ mergeRequestId: '',
+ appUrl: '',
+ }),
+ },
+ },
+ data() {
+ return {
+ actionInProgress: null,
+ constants: {
+ STOPPING,
+ DEPLOYING,
+ REDEPLOYING,
+ },
+ };
+ },
+ computed: {
+ appButtonText() {
+ return {
+ text: this.isCurrent ? s__('Review App|View app') : s__('Review App|View latest app'),
+ tooltip: this.isCurrent
+ ? ''
+ : __('View the latest successful deployment to this environment'),
+ };
+ },
+ canBeManuallyDeployed() {
+ return this.computedDeploymentStatus === MANUAL_DEPLOY && Boolean(this.playPath);
+ },
+ canBeManuallyRedeployed() {
+ return this.computedDeploymentStatus === FAILED && Boolean(this.redeployPath);
+ },
+ shouldShowManualButtons() {
+ return this.glFeatures.deployFromFooter;
+ },
+ hasExternalUrls() {
+ return Boolean(this.deployment.external_url && this.deployment.external_url_formatted);
+ },
+ isCurrent() {
+ return this.computedDeploymentStatus === SUCCESS;
+ },
+ playPath() {
+ return this.deployment.details?.playable_build?.play_path;
+ },
+ redeployPath() {
+ return this.deployment.details?.playable_build?.retry_path;
+ },
+ stopUrl() {
+ return this.deployment.stop_url;
+ },
+ },
+ actionsConfiguration: {
+ [STOPPING]: {
+ actionName: STOPPING,
+ buttonText: s__('MrDeploymentActions|Stop environment'),
+ busyText: __('This environment is being deployed'),
+ confirmMessage: __('Are you sure you want to stop this environment?'),
+ errorMessage: __('Something went wrong while stopping this environment. Please try again.'),
+ },
+ [DEPLOYING]: {
+ actionName: DEPLOYING,
+ buttonText: s__('MrDeploymentActions|Deploy'),
+ busyText: __('This environment is being deployed'),
+ confirmMessage: __('Are you sure you want to deploy this environment?'),
+ errorMessage: __('Something went wrong while deploying this environment. Please try again.'),
+ },
+ [REDEPLOYING]: {
+ actionName: REDEPLOYING,
+ buttonText: s__('MrDeploymentActions|Re-deploy'),
+ busyText: __('This environment is being re-deployed'),
+ confirmMessage: __('Are you sure you want to re-deploy this environment?'),
+ errorMessage: __('Something went wrong while deploying this environment. Please try again.'),
+ },
+ },
+ methods: {
+ executeAction(endpoint, { actionName, confirmMessage, errorMessage }) {
+ const isConfirmed = confirm(confirmMessage); //eslint-disable-line
+
+ if (isConfirmed) {
+ this.actionInProgress = actionName;
+
+ MRWidgetService.executeInlineAction(endpoint)
+ .then(resp => {
+ const redirectUrl = resp?.data?.redirect_url;
+ if (redirectUrl) {
+ visitUrl(redirectUrl);
+ }
+ })
+ .catch(() => {
+ createFlash(errorMessage);
+ })
+ .finally(() => {
+ this.actionInProgress = null;
+ });
+ }
+ },
+ stopEnvironment() {
+ this.executeAction(this.stopUrl, this.$options.actionsConfiguration[STOPPING]);
+ },
+ deployManually() {
+ this.executeAction(this.playPath, this.$options.actionsConfiguration[DEPLOYING]);
+ },
+ redeploy() {
+ this.executeAction(this.redeployPath, this.$options.actionsConfiguration[REDEPLOYING]);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <deployment-action-button
+ v-if="shouldShowManualButtons && canBeManuallyDeployed"
+ :action-in-progress="actionInProgress"
+ :actions-configuration="$options.actionsConfiguration[constants.DEPLOYING]"
+ :computed-deployment-status="computedDeploymentStatus"
+ container-classes="js-manual-deploy-action"
+ @click="deployManually"
+ >
+ <gl-icon name="play" />
+ <span>{{ $options.actionsConfiguration[constants.DEPLOYING].buttonText }}</span>
+ </deployment-action-button>
+ <deployment-action-button
+ v-if="shouldShowManualButtons && canBeManuallyRedeployed"
+ :action-in-progress="actionInProgress"
+ :actions-configuration="$options.actionsConfiguration[constants.REDEPLOYING]"
+ :computed-deployment-status="computedDeploymentStatus"
+ container-classes="js-manual-redeploy-action"
+ @click="redeploy"
+ >
+ <gl-icon name="repeat" />
+ <span>{{ $options.actionsConfiguration[constants.REDEPLOYING].buttonText }}</span>
+ </deployment-action-button>
+ <deployment-view-button
+ v-if="hasExternalUrls"
+ :app-button-text="appButtonText"
+ :deployment="deployment"
+ :show-visual-review-app="showVisualReviewApp"
+ :visual-review-app-metadata="visualReviewAppMeta"
+ />
+ <deployment-action-button
+ v-if="stopUrl"
+ :action-in-progress="actionInProgress"
+ :computed-deployment-status="computedDeploymentStatus"
+ :actions-configuration="$options.actionsConfiguration[constants.STOPPING]"
+ :button-title="$options.actionsConfiguration[constants.STOPPING].buttonText"
+ container-classes="js-stop-env"
+ @click="stopEnvironment"
+ >
+ <gl-icon name="stop" />
+ </deployment-action-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue
deleted file mode 100644
index e20296c41a2..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue
+++ /dev/null
@@ -1,83 +0,0 @@
-<script>
-import { GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-import Icon from '~/vue_shared/components/icon.vue';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
-import { visitUrl } from '~/lib/utils/url_utility';
-import createFlash from '~/flash';
-import MRWidgetService from '../../services/mr_widget_service';
-
-export default {
- name: 'DeploymentStopButton',
- components: {
- LoadingButton,
- Icon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- isDeployInProgress: {
- type: Boolean,
- required: true,
- },
- stopUrl: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- isStopping: false,
- };
- },
- computed: {
- deployInProgressTooltip() {
- return this.isDeployInProgress
- ? __('Stopping this environment is currently not possible as a deployment is in progress')
- : '';
- },
- },
- methods: {
- stopEnvironment() {
- const msg = __('Are you sure you want to stop this environment?');
- const isConfirmed = confirm(msg); // eslint-disable-line
-
- if (isConfirmed) {
- this.isStopping = true;
-
- MRWidgetService.stopEnvironment(this.stopUrl)
- .then(res => res.data)
- .then(data => {
- if (data.redirect_url) {
- visitUrl(data.redirect_url);
- }
-
- this.isStopping = false;
- })
- .catch(() => {
- createFlash(
- __('Something went wrong while stopping this environment. Please try again.'),
- );
- this.isStopping = false;
- });
- }
- },
- },
-};
-</script>
-
-<template>
- <span v-gl-tooltip :title="deployInProgressTooltip" class="d-inline-block" tabindex="0">
- <loading-button
- v-gl-tooltip
- :loading="isStopping"
- :disabled="isDeployInProgress"
- :title="__('Stop environment')"
- container-class="js-stop-env btn btn-default btn-sm inline prepend-left-4"
- @click="stopEnvironment"
- >
- <icon name="stop" />
- </loading-button>
- </span>
-</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
index d22cb4ced80..c620023a6d6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
+++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
@@ -54,7 +54,7 @@ export default class MRWidgetService {
return axios.post(this.endpoints.rebasePath);
}
- static stopEnvironment(url) {
+ static executeInlineAction(url) {
return axios.post(url);
}
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index c5f017efe8d..bea24d2b204 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -20,6 +20,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
before_action only: [:show] do
push_frontend_feature_flag(:diffs_batch_load, @project, default_enabled: true)
+ push_frontend_feature_flag(:deploy_from_footer, @project, default_enabled: true)
push_frontend_feature_flag(:single_mr_diff_view, @project)
push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline)
end
diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb
index ea5776534d5..5a4a223093e 100644
--- a/app/graphql/gitlab_schema.rb
+++ b/app/graphql/gitlab_schema.rb
@@ -141,3 +141,5 @@ class GitlabSchema < GraphQL::Schema
end
end
end
+
+GitlabSchema.prepend_if_ee('EE::GitlabSchema')
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 1dc5503d368..1516e33a7c6 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -43,11 +43,7 @@ module MergeRequests
abort_auto_merge(merge_request, 'target branch was changed')
end
- if merge_request.assignees != old_assignees
- create_assignee_note(merge_request, old_assignees)
- notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees)
- todo_service.reassigned_issuable(merge_request, current_user, old_assignees)
- end
+ handle_assignees_change(merge_request, old_assignees) if merge_request.assignees != old_assignees
if merge_request.previous_changes.include?('target_branch') ||
merge_request.previous_changes.include?('source_branch')
@@ -120,6 +116,12 @@ module MergeRequests
end
end
+ def handle_assignees_change(merge_request, old_assignees)
+ create_assignee_note(merge_request, old_assignees)
+ notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees)
+ todo_service.reassigned_issuable(merge_request, current_user, old_assignees)
+ end
+
def create_branch_change_note(issuable, branch_type, old_branch, new_branch)
SystemNoteService.change_branch(
issuable, issuable.project, current_user, branch_type,
diff --git a/changelogs/unreleased/25351-add-buttons.yml b/changelogs/unreleased/25351-add-buttons.yml
new file mode 100644
index 00000000000..3a324b41c45
--- /dev/null
+++ b/changelogs/unreleased/25351-add-buttons.yml
@@ -0,0 +1,5 @@
+---
+title: Add deploy and re-deploy buttons to deployments
+merge_request: 25427
+author:
+type: added
diff --git a/db/migrate/20200305121159_add_merge_request_metrics_first_reassigned_at.rb b/db/migrate/20200305121159_add_merge_request_metrics_first_reassigned_at.rb
new file mode 100644
index 00000000000..714adf925ed
--- /dev/null
+++ b/db/migrate/20200305121159_add_merge_request_metrics_first_reassigned_at.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class AddMergeRequestMetricsFirstReassignedAt < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ with_lock_retries do
+ add_column :merge_request_metrics, :first_reassigned_at, :datetime_with_timezone
+ end
+ end
+
+ def down
+ with_lock_retries do
+ remove_column :merge_request_metrics, :first_reassigned_at, :datetime_with_timezone
+ end
+ end
+end
diff --git a/db/migrate/20200306095654_add_merge_request_assignee_created_at.rb b/db/migrate/20200306095654_add_merge_request_assignee_created_at.rb
new file mode 100644
index 00000000000..127a20f127b
--- /dev/null
+++ b/db/migrate/20200306095654_add_merge_request_assignee_created_at.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class AddMergeRequestAssigneeCreatedAt < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ with_lock_retries do
+ add_column :merge_request_assignees, :created_at, :datetime_with_timezone
+ end
+ end
+
+ def down
+ with_lock_retries do
+ remove_column :merge_request_assignees, :created_at
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 32cc771c396..40cf50354db 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -2450,6 +2450,7 @@ ActiveRecord::Schema.define(version: 2020_03_06_170531) do
create_table "merge_request_assignees", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "merge_request_id", null: false
+ t.datetime_with_timezone "created_at"
t.index ["merge_request_id", "user_id"], name: "index_merge_request_assignees_on_merge_request_id_and_user_id", unique: true
t.index ["merge_request_id"], name: "index_merge_request_assignees_on_merge_request_id"
t.index ["user_id"], name: "index_merge_request_assignees_on_user_id"
@@ -2564,6 +2565,7 @@ ActiveRecord::Schema.define(version: 2020_03_06_170531) do
t.integer "modified_paths_size"
t.integer "commits_count"
t.datetime_with_timezone "first_approved_at"
+ t.datetime_with_timezone "first_reassigned_at"
t.index ["first_deployed_to_production_at"], name: "index_merge_request_metrics_on_first_deployed_to_production_at"
t.index ["latest_closed_at"], name: "index_merge_request_metrics_on_latest_closed_at", where: "(latest_closed_at IS NOT NULL)"
t.index ["latest_closed_by_id"], name: "index_merge_request_metrics_on_latest_closed_by_id"
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index 336c21ef6a1..fc26af40c69 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -1948,8 +1948,8 @@ type Epic implements Noteable {
descendantCounts: EpicDescendantCount
"""
- Total weight of open and closed descendant epic's issues. Available only when
- feature flag `unfiltered_epic_aggregates` is enabled.
+ Total weight of open and closed issues in the epic and its descendants.
+ Available only when feature flag `unfiltered_epic_aggregates` is enabled.
"""
descendantWeightSum: EpicDescendantWeights
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 923cc7dff3d..7230be484e6 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -317,7 +317,7 @@ Represents an epic.
| `closedAt` | Time | Timestamp of the epic's closure |
| `createdAt` | Time | Timestamp of the epic's creation |
| `descendantCounts` | EpicDescendantCount | Number of open and closed descendant epics and issues |
-| `descendantWeightSum` | EpicDescendantWeights | Total weight of open and closed descendant epic's issues. Available only when feature flag `unfiltered_epic_aggregates` is enabled. |
+| `descendantWeightSum` | EpicDescendantWeights | Total weight of open and closed issues in the epic and its descendants. Available only when feature flag `unfiltered_epic_aggregates` is enabled. |
| `description` | String | Description of the epic |
| `downvotes` | Int! | Number of downvotes the epic has received |
| `dueDate` | Time | Due date of the epic |
diff --git a/doc/user/project/merge_requests/squash_and_merge.md b/doc/user/project/merge_requests/squash_and_merge.md
index 82580a1c1fa..924334055b9 100644
--- a/doc/user/project/merge_requests/squash_and_merge.md
+++ b/doc/user/project/merge_requests/squash_and_merge.md
@@ -29,6 +29,9 @@ The squashed commit's commit message will be either:
- Taken from the first multi-line commit message in the merge.
- The merge request's title if no multi-line commit message is found.
+NOTE: **Note:**
+This only takes effect if there are at least 2 commits. As there is nothing to squash, the commit message does not change if there is only 1 commit.
+
It can be customized before merging a merge request.
![A squash commit message editor](img/squash_mr_message.png)
diff --git a/lib/gitlab/danger/commit_linter.rb b/lib/gitlab/danger/commit_linter.rb
index c9cb66e1718..8f51ef05f69 100644
--- a/lib/gitlab/danger/commit_linter.rb
+++ b/lib/gitlab/danger/commit_linter.rb
@@ -200,7 +200,9 @@ module Gitlab
end
def subject_starts_with_lowercase?
- first_char = subject[0]
+ first_char = subject.sub(/\A\[.+\]\s/, '')[0]
+ first_char_downcased = first_char.downcase
+ return true unless ('a'..'z').cover?(first_char_downcased)
first_char.downcase == first_char
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ef0ce1bbad6..b0960e66ea7 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2038,6 +2038,9 @@ msgstr ""
msgid "Anonymous"
msgstr ""
+msgid "Another action is currently in progress"
+msgstr ""
+
msgid "Another issue tracker is already in use. Only one issue tracker service can be active at a time"
msgstr ""
@@ -2283,6 +2286,9 @@ msgstr ""
msgid "Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone."
msgstr ""
+msgid "Are you sure you want to deploy this environment?"
+msgstr ""
+
msgid "Are you sure you want to erase this build?"
msgstr ""
@@ -2298,6 +2304,9 @@ msgstr ""
msgid "Are you sure you want to permanently delete this license?"
msgstr ""
+msgid "Are you sure you want to re-deploy this environment?"
+msgstr ""
+
msgid "Are you sure you want to regenerate the public key? You will have to update the public key on the remote server before mirroring will work again."
msgstr ""
@@ -12799,6 +12808,15 @@ msgstr ""
msgid "Moves this issue to %{path_to_project}."
msgstr ""
+msgid "MrDeploymentActions|Deploy"
+msgstr ""
+
+msgid "MrDeploymentActions|Re-deploy"
+msgstr ""
+
+msgid "MrDeploymentActions|Stop environment"
+msgstr ""
+
msgid "Multiple issue boards"
msgstr ""
@@ -18243,6 +18261,9 @@ msgstr ""
msgid "Something went wrong while deleting your note. Please try again."
msgstr ""
+msgid "Something went wrong while deploying this environment. Please try again."
+msgstr ""
+
msgid "Something went wrong while editing your comment. Please try again."
msgstr ""
@@ -18741,9 +18762,6 @@ msgstr ""
msgid "Stop Terminal"
msgstr ""
-msgid "Stop environment"
-msgstr ""
-
msgid "Stop impersonation"
msgstr ""
@@ -18753,9 +18771,6 @@ msgstr ""
msgid "Stopped"
msgstr ""
-msgid "Stopping this environment is currently not possible as a deployment is in progress"
-msgstr ""
-
msgid "Stopping..."
msgstr ""
@@ -20002,6 +20017,12 @@ msgstr ""
msgid "This environment has no deployments yet."
msgstr ""
+msgid "This environment is being deployed"
+msgstr ""
+
+msgid "This environment is being re-deployed"
+msgstr ""
+
msgid "This epic already has the maximum number of child epics."
msgstr ""
diff --git a/scripts/review_apps/automated_cleanup.rb b/scripts/review_apps/automated_cleanup.rb
index 93412b658f6..0a073a28bf3 100755
--- a/scripts/review_apps/automated_cleanup.rb
+++ b/scripts/review_apps/automated_cleanup.rb
@@ -81,10 +81,13 @@ class AutomatedCleanup
release = Quality::HelmClient::Release.new(environment.slug, 1, deployed_at.to_s, nil, nil, review_apps_namespace)
releases_to_delete << release
end
- elsif environment.state != 'stopped' && deployed_at < stop_threshold
- stop_environment(environment, deployment)
else
- print_release_state(subject: 'Review App', release_name: environment.slug, release_date: last_deploy, action: 'leaving')
+ if deployed_at >= stop_threshold
+ print_release_state(subject: 'Review App', release_name: environment.slug, release_date: last_deploy, action: 'leaving')
+ else
+ environment_state = fetch_environment(environment)&.state
+ stop_environment(environment, deployment) if environment_state && environment_state != 'stopped'
+ end
end
checked_environments << environment.slug
@@ -116,12 +119,19 @@ class AutomatedCleanup
private
+ def fetch_environment(environment)
+ gitlab.environment(project_path, environment.id)
+ rescue Errno::ETIMEDOUT => ex
+ puts "Failed to fetch '#{environment.name}' / '#{environment.slug}' (##{environment.id}):\n#{ex.message}"
+ nil
+ end
+
def delete_environment(environment, deployment)
print_release_state(subject: 'Review app', release_name: environment.slug, release_date: deployment.created_at, action: 'deleting')
gitlab.delete_environment(project_path, environment.id)
rescue Gitlab::Error::Forbidden
- puts "Review app '#{environment.slug}' is forbidden: skipping it"
+ puts "Review app '#{environment.name}' / '#{environment.slug}' (##{environment.id}) is forbidden: skipping it"
end
def stop_environment(environment, deployment)
@@ -129,7 +139,7 @@ class AutomatedCleanup
gitlab.stop_environment(project_path, environment.id)
rescue Gitlab::Error::Forbidden
- puts "Review app '#{environment.slug}' is forbidden: skipping it"
+ puts "Review app '#{environment.name}' / '#{environment.slug}' (##{environment.id}) is forbidden: skipping it"
end
def helm_releases
diff --git a/spec/frontend/notes/helpers.js b/spec/frontend/notes/helpers.js
new file mode 100644
index 00000000000..3f349b40ba5
--- /dev/null
+++ b/spec/frontend/notes/helpers.js
@@ -0,0 +1,12 @@
+// eslint-disable-next-line import/prefer-default-export
+export const resetStore = store => {
+ store.replaceState({
+ notes: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+
+ notesData: {},
+ userData: {},
+ noteableData: {},
+ });
+};
diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index ec1f1392845..40b0134e12e 100644
--- a/spec/javascripts/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -1,8 +1,8 @@
-import $ from 'jquery';
import { TEST_HOST } from 'spec/test_constants';
import AxiosMockAdapter from 'axios-mock-adapter';
import Api from '~/api';
-import actionsModule, * as actions from '~/notes/stores/actions';
+import Flash from '~/flash';
+import * as actions from '~/notes/stores/actions';
import * as mutationTypes from '~/notes/stores/mutation_types';
import * as notesConstants from '~/notes/constants';
import createStore from '~/notes/stores';
@@ -19,21 +19,20 @@ import {
import axios from '~/lib/utils/axios_utils';
const TEST_ERROR_MESSAGE = 'Test error message';
+jest.mock('~/flash');
describe('Actions Notes Store', () => {
let commit;
let dispatch;
let state;
let store;
- let flashSpy;
let axiosMock;
beforeEach(() => {
store = createStore();
- commit = jasmine.createSpy('commit');
- dispatch = jasmine.createSpy('dispatch');
+ commit = jest.fn();
+ dispatch = jest.fn();
state = {};
- flashSpy = spyOnDependency(actionsModule, 'Flash');
axiosMock = new AxiosMockAdapter(axios);
});
@@ -244,10 +243,10 @@ describe('Actions Notes Store', () => {
});
describe('poll', () => {
- beforeEach(done => {
- jasmine.clock().install();
+ jest.useFakeTimers();
- spyOn(axios, 'get').and.callThrough();
+ beforeEach(done => {
+ jest.spyOn(axios, 'get');
store
.dispatch('setNotesData', notesDataMock)
@@ -255,10 +254,6 @@ describe('Actions Notes Store', () => {
.catch(done.fail);
});
- afterEach(() => {
- jasmine.clock().uninstall();
- });
-
it('calls service with last fetched state', done => {
axiosMock
.onAny()
@@ -271,7 +266,7 @@ describe('Actions Notes Store', () => {
expect(axios.get).toHaveBeenCalled();
expect(store.state.lastFetchedAt).toBe('123456');
- jasmine.clock().tick(1500);
+ jest.advanceTimersByTime(1500);
})
.then(
() =>
@@ -280,8 +275,8 @@ describe('Actions Notes Store', () => {
}),
)
.then(() => {
- expect(axios.get.calls.count()).toBe(2);
- expect(axios.get.calls.mostRecent().args[1].headers).toEqual({
+ expect(axios.get.mock.calls.length).toBe(2);
+ expect(axios.get.mock.calls[axios.get.mock.calls.length - 1][1].headers).toEqual({
'X-Last-Fetched-At': '123456',
});
})
@@ -310,13 +305,13 @@ describe('Actions Notes Store', () => {
beforeEach(() => {
axiosMock.onDelete(endpoint).replyOnce(200, {});
- $('body').attr('data-page', '');
+ document.body.setAttribute('data-page', '');
});
afterEach(() => {
axiosMock.restore();
- $('body').attr('data-page', '');
+ document.body.setAttribute('data-page', '');
});
it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', done => {
@@ -347,7 +342,7 @@ describe('Actions Notes Store', () => {
it('dispatches removeDiscussionsFromDiff on merge request page', done => {
const note = { path: endpoint, id: 1 };
- $('body').attr('data-page', 'projects:merge_requests:show');
+ document.body.setAttribute('data-page', 'projects:merge_requests:show');
testAction(
actions.removeNote,
@@ -381,13 +376,13 @@ describe('Actions Notes Store', () => {
beforeEach(() => {
axiosMock.onDelete(endpoint).replyOnce(200, {});
- $('body').attr('data-page', '');
+ document.body.setAttribute('data-page', '');
});
afterEach(() => {
axiosMock.restore();
- $('body').attr('data-page', '');
+ document.body.setAttribute('data-page', '');
});
it('dispatches removeNote', done => {
@@ -534,7 +529,7 @@ describe('Actions Notes Store', () => {
describe('updateMergeRequestWidget', () => {
it('calls mrWidget checkStatus', () => {
- spyOn(mrWidgetEventHub, '$emit');
+ jest.spyOn(mrWidgetEventHub, '$emit').mockImplementation(() => {});
actions.updateMergeRequestWidget();
@@ -589,7 +584,7 @@ describe('Actions Notes Store', () => {
actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]);
- expect(commit.calls.allArgs()).toEqual([[mutationTypes.UPDATE_NOTE, note]]);
+ expect(commit.mock.calls).toEqual([[mutationTypes.UPDATE_NOTE, note]]);
});
it('Creates a new note if none exisits', () => {
@@ -597,7 +592,7 @@ describe('Actions Notes Store', () => {
const getters = { notesById: {} };
actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]);
- expect(commit.calls.allArgs()).toEqual([[mutationTypes.ADD_NEW_NOTE, note]]);
+ expect(commit.mock.calls).toEqual([[mutationTypes.ADD_NEW_NOTE, note]]);
});
describe('Discussion notes', () => {
@@ -619,7 +614,7 @@ describe('Actions Notes Store', () => {
actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [discussionNote]);
- expect(commit.calls.allArgs()).toEqual([
+ expect(commit.mock.calls).toEqual([
[mutationTypes.ADD_NEW_REPLY_TO_DISCUSSION, discussionNote],
]);
});
@@ -630,7 +625,7 @@ describe('Actions Notes Store', () => {
actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [diffNote]);
- expect(dispatch.calls.allArgs()).toEqual([
+ expect(dispatch.mock.calls).toEqual([
['fetchDiscussions', { path: state.notesData.discussionsPath }],
]);
});
@@ -645,7 +640,7 @@ describe('Actions Notes Store', () => {
actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [discussionNote]);
- expect(commit.calls.allArgs()).toEqual([[mutationTypes.ADD_NEW_NOTE, discussionNote]]);
+ expect(commit.mock.calls).toEqual([[mutationTypes.ADD_NEW_NOTE, discussionNote]]);
});
});
});
@@ -770,7 +765,7 @@ describe('Actions Notes Store', () => {
.then(() => done.fail('Expected error to be thrown!'))
.catch(err => {
expect(err).toBe(error);
- expect(flashSpy).not.toHaveBeenCalled();
+ expect(Flash).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
@@ -792,7 +787,7 @@ describe('Actions Notes Store', () => {
)
.then(resp => {
expect(resp.hasFlash).toBe(true);
- expect(flashSpy).toHaveBeenCalledWith(
+ expect(Flash).toHaveBeenCalledWith(
'Your comment could not be submitted because something went wrong',
'alert',
flashContainer,
@@ -818,7 +813,7 @@ describe('Actions Notes Store', () => {
)
.then(data => {
expect(data).toBe(res);
- expect(flashSpy).not.toHaveBeenCalled();
+ expect(Flash).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
@@ -833,9 +828,8 @@ describe('Actions Notes Store', () => {
let flashContainer;
beforeEach(() => {
- spyOn(Api, 'applySuggestion');
- dispatch.and.returnValue(Promise.resolve());
- Api.applySuggestion.and.returnValue(Promise.resolve());
+ jest.spyOn(Api, 'applySuggestion').mockReturnValue(Promise.resolve());
+ dispatch.mockReturnValue(Promise.resolve());
flashContainer = {};
});
@@ -852,32 +846,32 @@ describe('Actions Notes Store', () => {
it('when service success, commits and resolves discussion', done => {
testSubmitSuggestion(done, () => {
- expect(commit.calls.allArgs()).toEqual([
+ expect(commit.mock.calls).toEqual([
[mutationTypes.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }],
]);
- expect(dispatch.calls.allArgs()).toEqual([['resolveDiscussion', { discussionId }]]);
- expect(flashSpy).not.toHaveBeenCalled();
+ expect(dispatch.mock.calls).toEqual([['resolveDiscussion', { discussionId }]]);
+ expect(Flash).not.toHaveBeenCalled();
});
});
it('when service fails, flashes error message', done => {
const response = { response: { data: { message: TEST_ERROR_MESSAGE } } };
- Api.applySuggestion.and.returnValue(Promise.reject(response));
+ Api.applySuggestion.mockReturnValue(Promise.reject(response));
testSubmitSuggestion(done, () => {
expect(commit).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
- expect(flashSpy).toHaveBeenCalledWith(`${TEST_ERROR_MESSAGE}.`, 'alert', flashContainer);
+ expect(Flash).toHaveBeenCalledWith(`${TEST_ERROR_MESSAGE}.`, 'alert', flashContainer);
});
});
it('when resolve discussion fails, fail gracefully', done => {
- dispatch.and.returnValue(Promise.reject());
+ dispatch.mockReturnValue(Promise.reject());
testSubmitSuggestion(done, () => {
- expect(flashSpy).not.toHaveBeenCalled();
+ expect(Flash).not.toHaveBeenCalled();
});
});
});
@@ -887,13 +881,13 @@ describe('Actions Notes Store', () => {
const filter = 0;
beforeEach(() => {
- dispatch.and.returnValue(new Promise(() => {}));
+ dispatch.mockReturnValue(new Promise(() => {}));
});
it('fetches discussions with filter and persistFilter false', () => {
actions.filterDiscussion({ dispatch }, { path, filter, persistFilter: false });
- expect(dispatch.calls.allArgs()).toEqual([
+ expect(dispatch.mock.calls).toEqual([
['setLoadingState', true],
['fetchDiscussions', { path, filter, persistFilter: false }],
]);
@@ -902,7 +896,7 @@ describe('Actions Notes Store', () => {
it('fetches discussions with filter and persistFilter true', () => {
actions.filterDiscussion({ dispatch }, { path, filter, persistFilter: true });
- expect(dispatch.calls.allArgs()).toEqual([
+ expect(dispatch.mock.calls).toEqual([
['setLoadingState', true],
['fetchDiscussions', { path, filter, persistFilter: true }],
]);
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js
new file mode 100644
index 00000000000..1b14ee694fe
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js
@@ -0,0 +1,124 @@
+import { mount } from '@vue/test-utils';
+import { GlIcon, GlLoadingIcon, GlButton } from '@gitlab/ui';
+import DeploymentActionButton from '~/vue_merge_request_widget/components/deployment/deployment_action_button.vue';
+import {
+ CREATED,
+ RUNNING,
+ DEPLOYING,
+ REDEPLOYING,
+} from '~/vue_merge_request_widget/components/deployment/constants';
+import { actionButtonMocks } from './deployment_mock_data';
+
+const baseProps = {
+ actionsConfiguration: actionButtonMocks[DEPLOYING],
+ actionInProgress: null,
+ computedDeploymentStatus: CREATED,
+};
+
+describe('Deployment action button', () => {
+ let wrapper;
+
+ const factory = (options = {}) => {
+ wrapper = mount(DeploymentActionButton, {
+ ...options,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when passed only icon', () => {
+ beforeEach(() => {
+ factory({
+ propsData: baseProps,
+ slots: { default: ['<gl-icon name="stop" />'] },
+ stubs: {
+ 'gl-icon': GlIcon,
+ },
+ });
+ });
+
+ it('renders slot correctly', () => {
+ expect(wrapper.find(GlIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('when passed multiple items', () => {
+ beforeEach(() => {
+ factory({
+ propsData: baseProps,
+ slots: {
+ default: ['<gl-icon name="play" />', `<span>${actionButtonMocks[DEPLOYING]}</span>`],
+ },
+ stubs: {
+ 'gl-icon': GlIcon,
+ },
+ });
+ });
+
+ it('renders slot correctly', () => {
+ expect(wrapper.find(GlIcon).exists()).toBe(true);
+ expect(wrapper.text()).toContain(actionButtonMocks[DEPLOYING]);
+ });
+ });
+
+ describe('when its action is in progress', () => {
+ beforeEach(() => {
+ factory({
+ propsData: {
+ ...baseProps,
+ actionInProgress: actionButtonMocks[DEPLOYING].actionName,
+ },
+ });
+ });
+
+ it('is disabled and shows the loading icon', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.find(GlButton).props('disabled')).toBe(true);
+ });
+ });
+
+ describe('when another action is in progress', () => {
+ beforeEach(() => {
+ factory({
+ propsData: {
+ ...baseProps,
+ actionInProgress: actionButtonMocks[REDEPLOYING].actionName,
+ },
+ });
+ });
+ it('is disabled and does not show the loading icon', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(wrapper.find(GlButton).props('disabled')).toBe(true);
+ });
+ });
+
+ describe('when action status is running', () => {
+ beforeEach(() => {
+ factory({
+ propsData: {
+ ...baseProps,
+ actionInProgress: actionButtonMocks[REDEPLOYING].actionName,
+ computedDeploymentStatus: RUNNING,
+ },
+ });
+ });
+ it('is disabled and does not show the loading icon', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(wrapper.find(GlButton).props('disabled')).toBe(true);
+ });
+ });
+
+ describe('when no action is in progress', () => {
+ beforeEach(() => {
+ factory({
+ propsData: baseProps,
+ });
+ });
+ it('is not disabled nor does it show the loading icon', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(wrapper.find(GlButton).props('disabled')).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js
new file mode 100644
index 00000000000..6449272e6ed
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js
@@ -0,0 +1,220 @@
+import { mount } from '@vue/test-utils';
+import createFlash from '~/flash';
+import { visitUrl } from '~/lib/utils/url_utility';
+import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
+import DeploymentActions from '~/vue_merge_request_widget/components/deployment/deployment_actions.vue';
+import {
+ CREATED,
+ MANUAL_DEPLOY,
+ FAILED,
+ DEPLOYING,
+ REDEPLOYING,
+ STOPPING,
+} from '~/vue_merge_request_widget/components/deployment/constants';
+import {
+ actionButtonMocks,
+ deploymentMockData,
+ playDetails,
+ retryDetails,
+} from './deployment_mock_data';
+
+jest.mock('~/flash');
+jest.mock('~/lib/utils/url_utility');
+
+describe('DeploymentAction component', () => {
+ let wrapper;
+ let executeActionSpy;
+
+ const factory = (options = {}) => {
+ // This destroys any wrappers created before a nested call to factory reassigns it
+ if (wrapper && wrapper.destroy) {
+ wrapper.destroy();
+ }
+
+ wrapper = mount(DeploymentActions, {
+ ...options,
+ provide: { glFeatures: { deployFromFooter: true } },
+ });
+ };
+
+ const findStopButton = () => wrapper.find('.js-stop-env');
+ const findDeployButton = () => wrapper.find('.js-manual-deploy-action');
+ const findRedeployButton = () => wrapper.find('.js-manual-redeploy-action');
+
+ beforeEach(() => {
+ executeActionSpy = jest.spyOn(MRWidgetService, 'executeInlineAction');
+
+ factory({
+ propsData: {
+ computedDeploymentStatus: CREATED,
+ deployment: deploymentMockData,
+ showVisualReviewApp: false,
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('actions do not appear when conditions are unmet', () => {
+ describe('when there is no stop_url', () => {
+ beforeEach(() => {
+ factory({
+ propsData: {
+ computedDeploymentStatus: CREATED,
+ deployment: {
+ ...deploymentMockData,
+ stop_url: null,
+ },
+ showVisualReviewApp: false,
+ },
+ });
+ });
+
+ it('the stop button does not appear', () => {
+ expect(findStopButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when there is no play_path in details', () => {
+ it('the manual deploy button does not appear', () => {
+ expect(findDeployButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when there is no retry_path in details', () => {
+ it('the manual redeploy button does not appear', () => {
+ expect(findRedeployButton().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('when conditions are met', () => {
+ describe.each`
+ configConst | computedDeploymentStatus | displayConditionChanges | finderFn | endpoint
+ ${STOPPING} | ${CREATED} | ${{}} | ${findStopButton} | ${deploymentMockData.stop_url}
+ ${DEPLOYING} | ${MANUAL_DEPLOY} | ${playDetails} | ${findDeployButton} | ${playDetails.playable_build.play_path}
+ ${REDEPLOYING} | ${FAILED} | ${retryDetails} | ${findRedeployButton} | ${retryDetails.playable_build.retry_path}
+ `(
+ '$configConst action',
+ ({ configConst, computedDeploymentStatus, displayConditionChanges, finderFn, endpoint }) => {
+ describe(`${configConst} action`, () => {
+ const confirmAction = () => {
+ jest.spyOn(window, 'confirm').mockReturnValueOnce(true);
+ finderFn().trigger('click');
+ };
+
+ const rejectAction = () => {
+ jest.spyOn(window, 'confirm').mockReturnValueOnce(false);
+ finderFn().trigger('click');
+ };
+
+ beforeEach(() => {
+ factory({
+ propsData: {
+ computedDeploymentStatus,
+ deployment: {
+ ...deploymentMockData,
+ details: displayConditionChanges,
+ },
+ showVisualReviewApp: false,
+ },
+ });
+ });
+
+ it('the button is rendered', () => {
+ expect(finderFn().exists()).toBe(true);
+ });
+
+ describe('when clicked', () => {
+ describe('should show a confirm dialog but not call executeInlineAction when declined', () => {
+ beforeEach(() => {
+ executeActionSpy.mockResolvedValueOnce();
+ rejectAction();
+ });
+
+ it('should show the confirm dialog', () => {
+ expect(window.confirm).toHaveBeenCalled();
+ expect(window.confirm).toHaveBeenCalledWith(
+ actionButtonMocks[configConst].confirmMessage,
+ );
+ });
+
+ it('should not execute the action', () => {
+ expect(MRWidgetService.executeInlineAction).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('should show a confirm dialog and call executeInlineAction when accepted', () => {
+ beforeEach(() => {
+ executeActionSpy.mockResolvedValueOnce();
+ confirmAction();
+ });
+
+ it('should show the confirm dialog', () => {
+ expect(window.confirm).toHaveBeenCalled();
+ expect(window.confirm).toHaveBeenCalledWith(
+ actionButtonMocks[configConst].confirmMessage,
+ );
+ });
+
+ it('should execute the action with expected URL', () => {
+ expect(MRWidgetService.executeInlineAction).toHaveBeenCalled();
+ expect(MRWidgetService.executeInlineAction).toHaveBeenCalledWith(endpoint);
+ });
+
+ it('should not throw an error', () => {
+ expect(createFlash).not.toHaveBeenCalled();
+ });
+
+ describe('response includes redirect_url', () => {
+ const url = '/root/example';
+ beforeEach(() => {
+ executeActionSpy.mockResolvedValueOnce({
+ data: { redirect_url: url },
+ });
+ confirmAction();
+ });
+
+ it('calls visit url with the redirect_url', () => {
+ expect(visitUrl).toHaveBeenCalled();
+ expect(visitUrl).toHaveBeenCalledWith(url);
+ });
+ });
+
+ describe('it should call the executeAction method ', () => {
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'executeAction').mockImplementation();
+ confirmAction();
+ });
+
+ it('calls with the expected arguments', () => {
+ expect(wrapper.vm.executeAction).toHaveBeenCalled();
+ expect(wrapper.vm.executeAction).toHaveBeenCalledWith(
+ endpoint,
+ actionButtonMocks[configConst],
+ );
+ });
+ });
+
+ describe('when executeInlineAction errors', () => {
+ beforeEach(() => {
+ executeActionSpy.mockRejectedValueOnce();
+ confirmAction();
+ });
+
+ it('should call createFlash with error message', () => {
+ expect(createFlash).toHaveBeenCalled();
+ expect(createFlash).toHaveBeenCalledWith(
+ actionButtonMocks[configConst].errorMessage,
+ );
+ });
+ });
+ });
+ });
+ });
+ },
+ );
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_mock_data.js b/spec/frontend/vue_mr_widget/deployment/deployment_mock_data.js
index f8f4cb627dd..ff29022b75d 100644
--- a/spec/frontend/vue_mr_widget/deployment/deployment_mock_data.js
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_mock_data.js
@@ -1,4 +1,33 @@
-import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants';
+import {
+ DEPLOYING,
+ REDEPLOYING,
+ SUCCESS,
+ STOPPING,
+} from '~/vue_merge_request_widget/components/deployment/constants';
+
+const actionButtonMocks = {
+ [STOPPING]: {
+ actionName: STOPPING,
+ buttonText: 'Stop environment',
+ busyText: 'This environment is being deployed',
+ confirmMessage: 'Are you sure you want to stop this environment?',
+ errorMessage: 'Something went wrong while stopping this environment. Please try again.',
+ },
+ [DEPLOYING]: {
+ actionName: DEPLOYING,
+ buttonText: 'Deploy',
+ busyText: 'This environment is being deployed',
+ confirmMessage: 'Are you sure you want to deploy this environment?',
+ errorMessage: 'Something went wrong while deploying this environment. Please try again.',
+ },
+ [REDEPLOYING]: {
+ actionName: REDEPLOYING,
+ buttonText: 'Re-deploy',
+ busyText: 'This environment is being re-deployed',
+ confirmMessage: 'Are you sure you want to re-deploy this environment?',
+ errorMessage: 'Something went wrong while deploying this environment. Please try again.',
+ },
+};
const deploymentMockData = {
id: 15,
@@ -29,4 +58,16 @@ const deploymentMockData = {
],
};
-export default deploymentMockData;
+const playDetails = {
+ playable_build: {
+ play_path: '/root/test-deployments/-/jobs/1131/play',
+ },
+};
+
+const retryDetails = {
+ playable_build: {
+ retry_path: '/root/test-deployments/-/jobs/1131/retry',
+ },
+};
+
+export { actionButtonMocks, deploymentMockData, playDetails, retryDetails };
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js
index ec7be6b64fc..ce395de3b5d 100644
--- a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js
@@ -2,7 +2,6 @@ import { mount } from '@vue/test-utils';
import DeploymentComponent from '~/vue_merge_request_widget/components/deployment/deployment.vue';
import DeploymentInfo from '~/vue_merge_request_widget/components/deployment/deployment_info.vue';
import DeploymentViewButton from '~/vue_merge_request_widget/components/deployment/deployment_view_button.vue';
-import DeploymentStopButton from '~/vue_merge_request_widget/components/deployment/deployment_stop_button.vue';
import {
CREATED,
RUNNING,
@@ -10,15 +9,7 @@ import {
FAILED,
CANCELED,
} from '~/vue_merge_request_widget/components/deployment/constants';
-import deploymentMockData from './deployment_mock_data';
-
-const deployDetail = {
- playable_build: {
- retry_path: '/root/test-deployments/-/jobs/1131/retry',
- play_path: '/root/test-deployments/-/jobs/1131/play',
- },
- isManual: true,
-};
+import { deploymentMockData, playDetails, retryDetails } from './deployment_mock_data';
describe('Deployment component', () => {
let wrapper;
@@ -30,6 +21,7 @@ describe('Deployment component', () => {
}
wrapper = mount(DeploymentComponent, {
...options,
+ provide: { glFeatures: { deployFromFooter: true } },
});
};
@@ -53,28 +45,39 @@ describe('Deployment component', () => {
describe('status message and buttons', () => {
const noActions = [];
const noDetails = { isManual: false };
- const deployGroup = [DeploymentViewButton, DeploymentStopButton];
+ const deployDetail = {
+ ...playDetails,
+ isManual: true,
+ };
+
+ const retryDetail = {
+ ...retryDetails,
+ isManual: true,
+ };
+ const defaultGroup = ['.js-deploy-url', '.js-stop-env'];
+ const manualDeployGroup = ['.js-manual-deploy-action', ...defaultGroup];
+ const manualRedeployGroup = ['.js-manual-redeploy-action', ...defaultGroup];
describe.each`
status | previous | deploymentDetails | text | actionButtons
- ${CREATED} | ${true} | ${deployDetail} | ${'Can be manually deployed to'} | ${deployGroup}
- ${CREATED} | ${true} | ${noDetails} | ${'Will deploy to'} | ${deployGroup}
+ ${CREATED} | ${true} | ${deployDetail} | ${'Can be manually deployed to'} | ${manualDeployGroup}
+ ${CREATED} | ${true} | ${noDetails} | ${'Will deploy to'} | ${defaultGroup}
${CREATED} | ${false} | ${deployDetail} | ${'Can be manually deployed to'} | ${noActions}
${CREATED} | ${false} | ${noDetails} | ${'Will deploy to'} | ${noActions}
- ${RUNNING} | ${true} | ${deployDetail} | ${'Deploying to'} | ${deployGroup}
- ${RUNNING} | ${true} | ${noDetails} | ${'Deploying to'} | ${deployGroup}
+ ${RUNNING} | ${true} | ${deployDetail} | ${'Deploying to'} | ${defaultGroup}
+ ${RUNNING} | ${true} | ${noDetails} | ${'Deploying to'} | ${defaultGroup}
${RUNNING} | ${false} | ${deployDetail} | ${'Deploying to'} | ${noActions}
${RUNNING} | ${false} | ${noDetails} | ${'Deploying to'} | ${noActions}
- ${SUCCESS} | ${true} | ${deployDetail} | ${'Deployed to'} | ${deployGroup}
- ${SUCCESS} | ${true} | ${noDetails} | ${'Deployed to'} | ${deployGroup}
- ${SUCCESS} | ${false} | ${deployDetail} | ${'Deployed to'} | ${deployGroup}
- ${SUCCESS} | ${false} | ${noDetails} | ${'Deployed to'} | ${deployGroup}
- ${FAILED} | ${true} | ${deployDetail} | ${'Failed to deploy to'} | ${deployGroup}
- ${FAILED} | ${true} | ${noDetails} | ${'Failed to deploy to'} | ${deployGroup}
- ${FAILED} | ${false} | ${deployDetail} | ${'Failed to deploy to'} | ${noActions}
+ ${SUCCESS} | ${true} | ${deployDetail} | ${'Deployed to'} | ${defaultGroup}
+ ${SUCCESS} | ${true} | ${noDetails} | ${'Deployed to'} | ${defaultGroup}
+ ${SUCCESS} | ${false} | ${deployDetail} | ${'Deployed to'} | ${defaultGroup}
+ ${SUCCESS} | ${false} | ${noDetails} | ${'Deployed to'} | ${defaultGroup}
+ ${FAILED} | ${true} | ${retryDetail} | ${'Failed to deploy to'} | ${manualRedeployGroup}
+ ${FAILED} | ${true} | ${noDetails} | ${'Failed to deploy to'} | ${defaultGroup}
+ ${FAILED} | ${false} | ${retryDetail} | ${'Failed to deploy to'} | ${noActions}
${FAILED} | ${false} | ${noDetails} | ${'Failed to deploy to'} | ${noActions}
- ${CANCELED} | ${true} | ${deployDetail} | ${'Canceled deployment to'} | ${deployGroup}
- ${CANCELED} | ${true} | ${noDetails} | ${'Canceled deployment to'} | ${deployGroup}
+ ${CANCELED} | ${true} | ${deployDetail} | ${'Canceled deployment to'} | ${defaultGroup}
+ ${CANCELED} | ${true} | ${noDetails} | ${'Canceled deployment to'} | ${defaultGroup}
${CANCELED} | ${false} | ${deployDetail} | ${'Canceled deployment to'} | ${noActions}
${CANCELED} | ${false} | ${noDetails} | ${'Canceled deployment to'} | ${noActions}
`(
@@ -112,7 +115,7 @@ describe('Deployment component', () => {
if (actionButtons.length > 0) {
describe('renders the expected button group', () => {
actionButtons.forEach(button => {
- it(`renders ${button.name}`, () => {
+ it(`renders ${button}`, () => {
expect(wrapper.find(button).exists()).toBe(true);
});
});
@@ -121,8 +124,8 @@ describe('Deployment component', () => {
if (actionButtons.length === 0) {
describe('does not render the button group', () => {
- [DeploymentViewButton, DeploymentStopButton].forEach(button => {
- it(`does not render ${button.name}`, () => {
+ defaultGroup.forEach(button => {
+ it(`does not render ${button}`, () => {
expect(wrapper.find(button).exists()).toBe(false);
});
});
@@ -144,10 +147,6 @@ describe('Deployment component', () => {
describe('hasExternalUrls', () => {
describe('when deployment has both external_url_formatted and external_url', () => {
- it('should return true', () => {
- expect(wrapper.vm.hasExternalUrls).toEqual(true);
- });
-
it('should render the View Button', () => {
expect(wrapper.find(DeploymentViewButton).exists()).toBe(true);
});
@@ -163,10 +162,6 @@ describe('Deployment component', () => {
});
});
- it('should return false', () => {
- expect(wrapper.vm.hasExternalUrls).toEqual(false);
- });
-
it('should not render the View Button', () => {
expect(wrapper.find(DeploymentViewButton).exists()).toBe(false);
});
@@ -182,10 +177,6 @@ describe('Deployment component', () => {
});
});
- it('should return false', () => {
- expect(wrapper.vm.hasExternalUrls).toEqual(false);
- });
-
it('should not render the View Button', () => {
expect(wrapper.find(DeploymentViewButton).exists()).toBe(false);
});
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js
index 5e0f38459b0..a12757d4cce 100644
--- a/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js
@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils';
import DeploymentViewButton from '~/vue_merge_request_widget/components/deployment/deployment_view_button.vue';
import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue';
-import deploymentMockData from './deployment_mock_data';
+import { deploymentMockData } from './deployment_mock_data';
const appButtonText = {
text: 'View app',
diff --git a/spec/javascripts/notes/helpers.js b/spec/javascripts/notes/helpers.js
index 3f349b40ba5..7bcba609311 100644
--- a/spec/javascripts/notes/helpers.js
+++ b/spec/javascripts/notes/helpers.js
@@ -1,12 +1 @@
-// eslint-disable-next-line import/prefer-default-export
-export const resetStore = store => {
- store.replaceState({
- notes: [],
- targetNoteHash: null,
- lastFetchedAt: null,
-
- notesData: {},
- userData: {},
- noteableData: {},
- });
-};
+export * from '../../frontend/notes/helpers.js';
diff --git a/spec/javascripts/vue_mr_widget/components/deployment_stop_button_spec.js b/spec/javascripts/vue_mr_widget/components/deployment_stop_button_spec.js
deleted file mode 100644
index 6a6d8279c2c..00000000000
--- a/spec/javascripts/vue_mr_widget/components/deployment_stop_button_spec.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import Vue from 'vue';
-import deploymentStopComponent from '~/vue_merge_request_widget/components/deployment/deployment_stop_button.vue';
-import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants';
-import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
-import mountComponent from '../../helpers/vue_mount_component_helper';
-
-describe('Deployment component', () => {
- const Component = Vue.extend(deploymentStopComponent);
- let deploymentMockData;
-
- beforeEach(() => {
- deploymentMockData = {
- id: 15,
- name: 'review/diplo',
- url: '/root/review-apps/environments/15',
- stop_url: '/root/review-apps/environments/15/stop',
- metrics_url: '/root/review-apps/environments/15/deployments/1/metrics',
- metrics_monitoring_url: '/root/review-apps/environments/15/metrics',
- external_url: 'http://gitlab.com.',
- external_url_formatted: 'gitlab',
- deployed_at: '2017-03-22T22:44:42.258Z',
- deployed_at_formatted: 'Mar 22, 2017 10:44pm',
- deployment_manual_actions: [],
- status: SUCCESS,
- changes: [
- {
- path: 'index.html',
- external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/index.html',
- },
- {
- path: 'imgs/gallery.html',
- external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/imgs/gallery.html',
- },
- {
- path: 'about/',
- external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/about/',
- },
- ],
- };
- });
-
- let vm;
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- stopUrl: deploymentMockData.stop_url,
- isDeployInProgress: false,
- });
- });
-
- describe('stopEnvironment', () => {
- const url = '/foo/bar';
- const returnPromise = () =>
- new Promise(resolve => {
- resolve({
- data: {
- redirect_url: url,
- },
- });
- });
- const mockStopEnvironment = () => {
- vm.stopEnvironment(deploymentMockData);
- return vm;
- };
-
- it('should show a confirm dialog and call service.stopEnvironment when confirmed', done => {
- spyOn(window, 'confirm').and.returnValue(true);
- spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(true));
- const visitUrl = spyOnDependency(deploymentStopComponent, 'visitUrl').and.returnValue(true);
- vm = mockStopEnvironment();
-
- expect(window.confirm).toHaveBeenCalled();
- expect(MRWidgetService.stopEnvironment).toHaveBeenCalledWith(deploymentMockData.stop_url);
- setTimeout(() => {
- expect(visitUrl).toHaveBeenCalledWith(url);
- done();
- }, 333);
- });
-
- it('should show a confirm dialog but should not work if the dialog is rejected', () => {
- spyOn(window, 'confirm').and.returnValue(false);
- spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(false));
- vm = mockStopEnvironment();
-
- expect(window.confirm).toHaveBeenCalled();
- expect(MRWidgetService.stopEnvironment).not.toHaveBeenCalled();
- });
- });
- });
-});
diff --git a/spec/lib/gitlab/danger/commit_linter_spec.rb b/spec/lib/gitlab/danger/commit_linter_spec.rb
index 3d0d51caf29..a4760c942dc 100644
--- a/spec/lib/gitlab/danger/commit_linter_spec.rb
+++ b/spec/lib/gitlab/danger/commit_linter_spec.rb
@@ -195,6 +195,39 @@ describe Gitlab::Danger::CommitLinter do
end
end
+ [
+ '[ci skip] A commit message',
+ '[Ci skip] A commit message',
+ '[API] A commit message'
+ ].each do |message|
+ context "when subject is '#{message}'" do
+ let(:commit_message) { message }
+
+ it 'does not add a problem' do
+ expect(commit_linter).not_to receive(:add_problem)
+
+ commit_linter.lint
+ end
+ end
+ end
+
+ [
+ '[ci skip]A commit message',
+ '[Ci skip] A commit message',
+ '[ci skip] a commit message',
+ '! A commit message'
+ ].each do |message|
+ context "when subject is '#{message}'" do
+ let(:commit_message) { message }
+
+ it 'adds a problem' do
+ expect(commit_linter).to receive(:add_problem).with(:subject_starts_with_lowercase, described_class::DEFAULT_SUBJECT_DESCRIPTION)
+
+ commit_linter.lint
+ end
+ end
+ end
+
context 'when subject ends with a period' do
let(:commit_message) { 'A B C.' }
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index 0b5fb7e4a9f..bbd83975f11 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -273,6 +273,7 @@ MergeRequest::Metrics:
- modified_paths_size
- commits_count
- first_approved_at
+- first_reassigned_at
Ci::Pipeline:
- id
- project_id