Commit 7c38405b authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 1fa79760
...@@ -55,7 +55,7 @@ eslint-report.html ...@@ -55,7 +55,7 @@ eslint-report.html
/dump.rdb /dump.rdb
/jsconfig.json /jsconfig.json
/log/*.log* /log/*.log*
/node_modules/ /node_modules
/nohup.out /nohup.out
/public/assets/ /public/assets/
/public/uploads.* /public/uploads.*
......
...@@ -6,3 +6,8 @@ export const RUNNING = 'running'; ...@@ -6,3 +6,8 @@ export const RUNNING = 'running';
export const SUCCESS = 'success'; export const SUCCESS = 'success';
export const FAILED = 'failed'; export const FAILED = 'failed';
export const CANCELED = 'canceled'; export const CANCELED = 'canceled';
// ACTION STATUSES
export const STOPPING = 'stopping';
export const DEPLOYING = 'deploying';
export const REDEPLOYING = 'redeploying';
<script> <script>
import { __, s__ } from '~/locale'; import DeploymentActions from './deployment_actions.vue';
import DeploymentInfo from './deployment_info.vue'; import DeploymentInfo from './deployment_info.vue';
import DeploymentViewButton from './deployment_view_button.vue'; import { MANUAL_DEPLOY, WILL_DEPLOY, CREATED } from './constants';
import DeploymentStopButton from './deployment_stop_button.vue';
import { MANUAL_DEPLOY, WILL_DEPLOY, CREATED, RUNNING, SUCCESS } from './constants';
export default { export default {
// name: 'Deployment' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives // 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 // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
name: 'Deployment', name: 'Deployment',
components: { components: {
DeploymentActions,
DeploymentInfo, DeploymentInfo,
DeploymentStopButton,
DeploymentViewButton,
}, },
props: { props: {
deployment: { deployment: {
...@@ -40,38 +37,14 @@ export default { ...@@ -40,38 +37,14 @@ export default {
}, },
}, },
computed: { 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() { computedDeploymentStatus() {
if (this.deployment.status === CREATED) { if (this.deployment.status === CREATED) {
return this.isManual ? MANUAL_DEPLOY : WILL_DEPLOY; return this.isManual ? MANUAL_DEPLOY : WILL_DEPLOY;
} }
return this.deployment.status; return this.deployment.status;
}, },
hasExternalUrls() {
return Boolean(this.deployment.external_url && this.deployment.external_url_formatted);
},
isCurrent() {
return this.computedDeploymentStatus === SUCCESS;
},
isManual() { isManual() {
return Boolean( return Boolean(this.deployment.details?.playable_build?.play_path);
this.deployment.details &&
this.deployment.details.playable_build &&
this.deployment.details.playable_build.play_path,
);
},
isDeployInProgress() {
return this.deployment.status === RUNNING;
}, },
}, },
}; };
...@@ -87,22 +60,12 @@ export default { ...@@ -87,22 +60,12 @@ export default {
:deployment="deployment" :deployment="deployment"
:show-metrics="showMetrics" :show-metrics="showMetrics"
/> />
<div> <deployment-actions
<!-- show appropriate version of review app button --> :deployment="deployment"
<deployment-view-button :computed-deployment-status="computedDeploymentStatus"
v-if="hasExternalUrls" :show-visual-review-app="showVisualReviewApp"
:app-button-text="appButtonText" :visual-review-app-metadata="visualReviewAppMeta"
: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>
</div> </div>
</div> </div>
</div> </div>
......
<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>
<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>
<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>
...@@ -54,7 +54,7 @@ export default class MRWidgetService { ...@@ -54,7 +54,7 @@ export default class MRWidgetService {
return axios.post(this.endpoints.rebasePath); return axios.post(this.endpoints.rebasePath);
} }
static stopEnvironment(url) { static executeInlineAction(url) {
return axios.post(url); return axios.post(url);
} }
......
...@@ -20,6 +20,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -20,6 +20,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :check_user_can_push_to_source_branch!, only: [:rebase] before_action :check_user_can_push_to_source_branch!, only: [:rebase]
before_action only: [:show] do before_action only: [:show] do
push_frontend_feature_flag(:diffs_batch_load, @project, default_enabled: true) 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(:single_mr_diff_view, @project)
push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline) push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline)
end end
......
...@@ -141,3 +141,5 @@ class GitlabSchema < GraphQL::Schema ...@@ -141,3 +141,5 @@ class GitlabSchema < GraphQL::Schema
end end
end end
end end
GitlabSchema.prepend_if_ee('EE::GitlabSchema')
...@@ -43,11 +43,7 @@ module MergeRequests ...@@ -43,11 +43,7 @@ module MergeRequests
abort_auto_merge(merge_request, 'target branch was changed') abort_auto_merge(merge_request, 'target branch was changed')
end end
if merge_request.assignees != old_assignees handle_assignees_change(merge_request, old_assignees) 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
if merge_request.previous_changes.include?('target_branch') || if merge_request.previous_changes.include?('target_branch') ||
merge_request.previous_changes.include?('source_branch') merge_request.previous_changes.include?('source_branch')
...@@ -120,6 +116,12 @@ module MergeRequests ...@@ -120,6 +116,12 @@ module MergeRequests
end end
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) def create_branch_change_note(issuable, branch_type, old_branch, new_branch)
SystemNoteService.change_branch( SystemNoteService.change_branch(
issuable, issuable.project, current_user, branch_type, issuable, issuable.project, current_user, branch_type,
......
---
title: Add deploy and re-deploy buttons to deployments
merge_request: 25427
author:
type: added
# 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
# 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
...@@ -2450,6 +2450,7 @@ ActiveRecord::Schema.define(version: 2020_03_06_170531) do ...@@ -2450,6 +2450,7 @@ ActiveRecord::Schema.define(version: 2020_03_06_170531) do
create_table "merge_request_assignees", force: :cascade do |t| create_table "merge_request_assignees", force: :cascade do |t|
t.integer "user_id", null: false t.integer "user_id", null: false
t.integer "merge_request_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", "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 ["merge_request_id"], name: "index_merge_request_assignees_on_merge_request_id"
t.index ["user_id"], name: "index_merge_request_assignees_on_user_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 ...@@ -2564,6 +2565,7 @@ ActiveRecord::Schema.define(version: 2020_03_06_170531) do
t.integer "modified_paths_size" t.integer "modified_paths_size"
t.integer "commits_count" t.integer "commits_count"
t.datetime_with_timezone "first_approved_at" 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 ["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_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" t.index ["latest_closed_by_id"], name: "index_merge_request_metrics_on_latest_closed_by_id"
......
...@@ -1948,8 +1948,8 @@ type Epic implements Noteable { ...@@ -1948,8 +1948,8 @@ type Epic implements Noteable {
descendantCounts: EpicDescendantCount descendantCounts: EpicDescendantCount
""" """
Total weight of open and closed descendant epic's issues. Available only when Total weight of open and closed issues in the epic and its descendants.
feature flag `unfiltered_epic_aggregates` is enabled. Available only when feature flag `unfiltered_epic_aggregates` is enabled.
""" """
descendantWeightSum: EpicDescendantWeights descendantWeightSum: EpicDescendantWeights
......
...@@ -317,7 +317,7 @@ Represents an epic. ...@@ -317,7 +317,7 @@ Represents an epic.
| `closedAt` | Time | Timestamp of the epic's closure | | `closedAt` | Time | Timestamp of the epic's closure |
| `createdAt` | Time | Timestamp of the epic's creation | | `createdAt` | Time | Timestamp of the epic's creation |
| `descendantCounts` | EpicDescendantCount | Number of open and closed descendant epics and issues | | `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 | | `description` | String | Description of the epic |
| `downvotes` | Int! | Number of downvotes the epic has received | | `downvotes` | Int! | Number of downvotes the epic has received |
| `dueDate` | Time | Due date of the epic | | `dueDate` | Time | Due date of the epic |
......
...@@ -29,6 +29,9 @@ The squashed commit's commit message will be either: ...@@ -29,6 +29,9 @@ The squashed commit's commit message will be either:
- Taken from the first multi-line commit message in the merge. - Taken from the first multi-line commit message in the merge.
- The merge request's title if no multi-line commit message is found. - 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. It can be customized before merging a merge request.
![A squash commit message editor](img/squash_mr_message.png) ![A squash commit message editor](img/squash_mr_message.png)
......
...@@ -200,7 +200,9 @@ module Gitlab ...@@ -200,7 +200,9 @@ module Gitlab
end end
def subject_starts_with_lowercase? 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 first_char.downcase == first_char
end end
......
...@@ -2038,6 +2038,9 @@ msgstr "" ...@@ -2038,6 +2038,9 @@ msgstr ""
msgid "Anonymous" msgid "Anonymous"
msgstr "" 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" msgid "Another issue tracker is already in use. Only one issue tracker service can be active at a time"
msgstr "" msgstr ""
...@@ -2283,6 +2286,9 @@ 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." 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 "" msgstr ""
msgid "Are you sure you want to deploy this environment?"
msgstr ""
msgid "Are you sure you want to erase this build?" msgid "Are you sure you want to erase this build?"
msgstr "" msgstr ""
...@@ -2298,6 +2304,9 @@ msgstr "" ...@@ -2298,6 +2304,9 @@ msgstr ""
msgid "Are you sure you want to permanently delete this license?" msgid "Are you sure you want to permanently delete this license?"
msgstr "" 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." 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 "" msgstr ""
...@@ -12799,6 +12808,15 @@ msgstr "" ...@@ -12799,6 +12808,15 @@ msgstr ""
msgid "Moves this issue to %{path_to_project}." msgid "Moves this issue to %{path_to_project}."
msgstr "" msgstr ""
msgid "MrDeploymentActions|Deploy"
msgstr ""
msgid "MrDeploymentActions|Re-deploy"
msgstr ""
msgid "MrDeploymentActions|Stop environment"
msgstr ""
msgid "Multiple issue boards" msgid "Multiple issue boards"
msgstr "" msgstr ""
...@@ -18243,6 +18261,9 @@ msgstr "" ...@@ -18243,6 +18261,9 @@ msgstr ""
msgid "Something went wrong while deleting your note. Please try again." msgid "Something went wrong while deleting your note. Please try again."
msgstr "" msgstr ""
msgid "Something went wrong while deploying this environment. Please try again."
msgstr ""
msgid "Something went wrong while editing your comment. Please try again." msgid "Something went wrong while editing your comment. Please try again."
msgstr "" msgstr ""
...@@ -18741,9 +18762,6 @@ msgstr "" ...@@ -18741,9 +18762,6 @@ msgstr ""
msgid "Stop Terminal" msgid "Stop Terminal"
msgstr "" msgstr ""
msgid "Stop environment"
msgstr ""
msgid "Stop impersonation" msgid "Stop impersonation"
msgstr "" msgstr ""
...@@ -18753,9 +18771,6 @@ msgstr "" ...@@ -18753,9 +18771,6 @@ msgstr ""
msgid "Stopped" msgid "Stopped"
msgstr "" msgstr ""
msgid "Stopping this environment is currently not possible as a deployment is in progress"
msgstr ""
msgid "Stopping..." msgid "Stopping..."
msgstr "" msgstr ""
...@@ -20002,6 +20017,12 @@ msgstr "" ...@@ -20002,6 +20017,12 @@ msgstr ""
msgid "This environment has no deployments yet." msgid "This environment has no deployments yet."
msgstr "" 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." msgid "This epic already has the maximum number of child epics."
msgstr "" msgstr ""
......
...@@ -81,10 +81,13 @@ class AutomatedCleanup ...@@ -81,10 +81,13 @@ class AutomatedCleanup
release = Quality::HelmClient::Release.new(environment.slug, 1, deployed_at.to_s, nil, nil, review_apps_namespace) release = Quality::HelmClient::Release.new(environment.slug, 1, deployed_at.to_s, nil, nil, review_apps_namespace)
releases_to_delete << release releases_to_delete << release
end end
elsif environment.state != 'stopped' && deployed_at < stop_threshold
stop_environment(environment, deployment)
else 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 end
checked_environments << environment.slug checked_environments << environment.slug
...@@ -116,12 +119,19 @@ class AutomatedCleanup ...@@ -116,12 +119,19 @@ class AutomatedCleanup
private 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) def delete_environment(environment, deployment)
print_release_state(subject: 'Review app', release_name: environment.slug, release_date: deployment.created_at, action: 'deleting') print_release_state(subject: 'Review app', release_name: environment.slug, release_date: deployment.created_at, action: 'deleting')
gitlab.delete_environment(project_path, environment.id) gitlab.delete_environment(project_path, environment.id)
rescue Gitlab::Error::Forbidden 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 end
def stop_environment(environment, deployment) def stop_environment(environment, deployment)
...@@ -129,7 +139,7 @@ class AutomatedCleanup ...@@ -129,7 +139,7 @@ class AutomatedCleanup
gitlab.stop_environment(project_path, environment.id) gitlab.stop_environment(project_path, environment.id)
rescue Gitlab::Error::Forbidden 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 end
def helm_releases def helm_releases
......
// eslint-disable-next-line import/prefer-default-export
export const resetStore = store => {
store.replaceState({
notes: [],
targetNoteHash: null,
lastFetchedAt: null,
notesData: {},
userData: {},
noteableData: {},
});
};
import $ from 'jquery';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import AxiosMockAdapter from 'axios-mock-adapter'; import AxiosMockAdapter from 'axios-mock-adapter';
import Api from '~/api'; 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 mutationTypes from '~/notes/stores/mutation_types';
import * as notesConstants from '~/notes/constants'; import * as notesConstants from '~/notes/constants';
import createStore from '~/notes/stores'; import createStore from '~/notes/stores';
...@@ -19,21 +19,20 @@ import { ...@@ -19,21 +19,20 @@ import {
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
const TEST_ERROR_MESSAGE = 'Test error message'; const TEST_ERROR_MESSAGE = 'Test error message';
jest.mock('~/flash');
describe('Actions Notes Store', () => { describe('Actions Notes Store', () => {
let commit; let commit;
let dispatch; let dispatch;
let state; let state;
let store; let store;
let flashSpy;
let axiosMock; let axiosMock;
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
commit = jasmine.createSpy('commit'); commit = jest.fn();
dispatch = jasmine.createSpy('dispatch'); dispatch = jest.fn();
state = {}; state = {};
flashSpy = spyOnDependency(actionsModule, 'Flash');
axiosMock = new AxiosMockAdapter(axios); axiosMock = new AxiosMockAdapter(axios);
}); });
...@@ -244,10 +243,10 @@ describe('Actions Notes Store', () => { ...@@ -244,10 +243,10 @@ describe('Actions Notes Store', () => {
}); });
describe('poll', () => { describe('poll', () => {
beforeEach(done => { jest.useFakeTimers();
jasmine.clock().install();
spyOn(axios, 'get').and.callThrough(); beforeEach(done => {
jest.spyOn(axios, 'get');
store store
.dispatch('setNotesData', notesDataMock) .dispatch('setNotesData', notesDataMock)
...@@ -255,10 +254,6 @@ describe('Actions Notes Store', () => { ...@@ -255,10 +254,6 @@ describe('Actions Notes Store', () => {
.catch(done.fail); .catch(done.fail);
}); });
afterEach(() => {
jasmine.clock().uninstall();
});
it('calls service with last fetched state', done => { it('calls service with last fetched state', done => {
axiosMock axiosMock
.onAny() .onAny()
...@@ -271,7 +266,7 @@ describe('Actions Notes Store', () => { ...@@ -271,7 +266,7 @@ describe('Actions Notes Store', () => {
expect(axios.get).toHaveBeenCalled(); expect(axios.get).toHaveBeenCalled();
expect(store.state.lastFetchedAt).toBe('123456'); expect(store.state.lastFetchedAt).toBe('123456');
jasmine.clock().tick(1500); jest.advanceTimersByTime(1500);
}) })
.then( .then(
() => () =>
...@@ -280,8 +275,8 @@ describe('Actions Notes Store', () => { ...@@ -280,8 +275,8 @@ describe('Actions Notes Store', () => {
}), }),
) )
.then(() => { .then(() => {
expect(axios.get.calls.count()).toBe(2); expect(axios.get.mock.calls.length).toBe(2);
expect(axios.get.calls.mostRecent().args[1].headers).toEqual({ expect(axios.get.mock.calls[axios.get.mock.calls.length - 1][1].headers).toEqual({
'X-Last-Fetched-At': '123456', 'X-Last-Fetched-At': '123456',
}); });
}) })
...@@ -310,13 +305,13 @@ describe('Actions Notes Store', () => { ...@@ -310,13 +305,13 @@ describe('Actions Notes Store', () => {
beforeEach(() => { beforeEach(() => {
axiosMock.onDelete(endpoint).replyOnce(200, {}); axiosMock.onDelete(endpoint).replyOnce(200, {});
$('body').attr('data-page', ''); document.body.setAttribute('data-page', '');
}); });
afterEach(() => { afterEach(() => {
axiosMock.restore(); axiosMock.restore();
$('body').attr('data-page', ''); document.body.setAttribute('data-page', '');
}); });
it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', done => { it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', done => {
...@@ -347,7 +342,7 @@ describe('Actions Notes Store', () => { ...@@ -347,7 +342,7 @@ describe('Actions Notes Store', () => {
it('dispatches removeDiscussionsFromDiff on merge request page', done => { it('dispatches removeDiscussionsFromDiff on merge request page', done => {
const note = { path: endpoint, id: 1 }; const note = { path: endpoint, id: 1 };
$('body').attr('data-page', 'projects:merge_requests:show'); document.body.setAttribute('data-page', 'projects:merge_requests:show');
testAction( testAction(
actions.removeNote, actions.removeNote,
...@@ -381,13 +376,13 @@ describe('Actions Notes Store', () => { ...@@ -381,13 +376,13 @@ describe('Actions Notes Store', () => {
beforeEach(() => { beforeEach(() => {
axiosMock.onDelete(endpoint).replyOnce(200, {}); axiosMock.onDelete(endpoint).replyOnce(200, {});
$('body').attr('data-page', ''); document.body.setAttribute('data-page', '');
}); });
afterEach(() => { afterEach(() => {
axiosMock.restore(); axiosMock.restore();
$('body').attr('data-page', ''); document.body.setAttribute('data-page', '');
}); });
it('dispatches removeNote', done => { it('dispatches removeNote', done => {
...@@ -534,7 +529,7 @@ describe('Actions Notes Store', () => { ...@@ -534,7 +529,7 @@ describe('Actions Notes Store', () => {
describe('updateMergeRequestWidget', () => { describe('updateMergeRequestWidget', () => {
it('calls mrWidget checkStatus', () => { it('calls mrWidget checkStatus', () => {
spyOn(mrWidgetEventHub, '$emit'); jest.spyOn(mrWidgetEventHub, '$emit').mockImplementation(() => {});
actions.updateMergeRequestWidget(); actions.updateMergeRequestWidget();
...@@ -589,7 +584,7 @@ describe('Actions Notes Store', () => { ...@@ -589,7 +584,7 @@ describe('Actions Notes Store', () => {
actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]); 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', () => { it('Creates a new note if none exisits', () => {
...@@ -597,7 +592,7 @@ describe('Actions Notes Store', () => { ...@@ -597,7 +592,7 @@ describe('Actions Notes Store', () => {
const getters = { notesById: {} }; const getters = { notesById: {} };
actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]); 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', () => { describe('Discussion notes', () => {
...@@ -619,7 +614,7 @@ describe('Actions Notes Store', () => { ...@@ -619,7 +614,7 @@ describe('Actions Notes Store', () => {
actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [discussionNote]); actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [discussionNote]);
expect(commit.calls.allArgs()).toEqual([ expect(commit.mock.calls).toEqual([
[mutationTypes.ADD_NEW_REPLY_TO_DISCUSSION, discussionNote], [mutationTypes.ADD_NEW_REPLY_TO_DISCUSSION, discussionNote],
]); ]);
}); });
...@@ -630,7 +625,7 @@ describe('Actions Notes Store', () => { ...@@ -630,7 +625,7 @@ describe('Actions Notes Store', () => {
actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [diffNote]); actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [diffNote]);
expect(dispatch.calls.allArgs()).toEqual([ expect(dispatch.mock.calls).toEqual([
['fetchDiscussions', { path: state.notesData.discussionsPath }], ['fetchDiscussions', { path: state.notesData.discussionsPath }],
]); ]);
}); });
...@@ -645,7 +640,7 @@ describe('Actions Notes Store', () => { ...@@ -645,7 +640,7 @@ describe('Actions Notes Store', () => {
actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [discussionNote]); 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', () => { ...@@ -770,7 +765,7 @@ describe('Actions Notes Store', () => {
.then(() => done.fail('Expected error to be thrown!')) .then(() => done.fail('Expected error to be thrown!'))
.catch(err => { .catch(err => {
expect(err).toBe(error); expect(err).toBe(error);
expect(flashSpy).not.toHaveBeenCalled(); expect(Flash).not.toHaveBeenCalled();
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
...@@ -792,7 +787,7 @@ describe('Actions Notes Store', () => { ...@@ -792,7 +787,7 @@ describe('Actions Notes Store', () => {
) )
.then(resp => { .then(resp => {
expect(resp.hasFlash).toBe(true); expect(resp.hasFlash).toBe(true);
expect(flashSpy).toHaveBeenCalledWith( expect(Flash).toHaveBeenCalledWith(
'Your comment could not be submitted because something went wrong', 'Your comment could not be submitted because something went wrong',
'alert', 'alert',
flashContainer, flashContainer,
...@@ -818,7 +813,7 @@ describe('Actions Notes Store', () => { ...@@ -818,7 +813,7 @@ describe('Actions Notes Store', () => {
) )
.then(data => { .then(data => {
expect(data).toBe(res); expect(data).toBe(res);
expect(flashSpy).not.toHaveBeenCalled(); expect(Flash).not.toHaveBeenCalled();
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
...@@ -833,9 +828,8 @@ describe('Actions Notes Store', () => { ...@@ -833,9 +828,8 @@ describe('Actions Notes Store', () => {
let flashContainer; let flashContainer;
beforeEach(() => { beforeEach(() => {
spyOn(Api, 'applySuggestion'); jest.spyOn(Api, 'applySuggestion').mockReturnValue(Promise.resolve());
dispatch.and.returnValue(Promise.resolve()); dispatch.mockReturnValue(Promise.resolve());
Api.applySuggestion.and.returnValue(Promise.resolve());
flashContainer = {}; flashContainer = {};
}); });
...@@ -852,32 +846,32 @@ describe('Actions Notes Store', () => { ...@@ -852,32 +846,32 @@ describe('Actions Notes Store', () => {
it('when service success, commits and resolves discussion', done => { it('when service success, commits and resolves discussion', done => {
testSubmitSuggestion(done, () => { testSubmitSuggestion(done, () => {
expect(commit.calls.allArgs()).toEqual([ expect(commit.mock.calls).toEqual([
[mutationTypes.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }], [mutationTypes.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }],
]); ]);
expect(dispatch.calls.allArgs()).toEqual([['resolveDiscussion', { discussionId }]]); expect(dispatch.mock.calls).toEqual([['resolveDiscussion', { discussionId }]]);
expect(flashSpy).not.toHaveBeenCalled(); expect(Flash).not.toHaveBeenCalled();
}); });
}); });
it('when service fails, flashes error message', done => { it('when service fails, flashes error message', done => {
const response = { response: { data: { message: TEST_ERROR_MESSAGE } } }; const response = { response: { data: { message: TEST_ERROR_MESSAGE } } };
Api.applySuggestion.and.returnValue(Promise.reject(response)); Api.applySuggestion.mockReturnValue(Promise.reject(response));
testSubmitSuggestion(done, () => { testSubmitSuggestion(done, () => {
expect(commit).not.toHaveBeenCalled(); expect(commit).not.toHaveBeenCalled();
expect(dispatch).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 => { it('when resolve discussion fails, fail gracefully', done => {
dispatch.and.returnValue(Promise.reject()); dispatch.mockReturnValue(Promise.reject());
testSubmitSuggestion(done, () => { testSubmitSuggestion(done, () => {
expect(flashSpy).not.toHaveBeenCalled(); expect(Flash).not.toHaveBeenCalled();
}); });
}); });
}); });
...@@ -887,13 +881,13 @@ describe('Actions Notes Store', () => { ...@@ -887,13 +881,13 @@ describe('Actions Notes Store', () => {
const filter = 0; const filter = 0;
beforeEach(() => { beforeEach(() => {
dispatch.and.returnValue(new Promise(() => {})); dispatch.mockReturnValue(new Promise(() => {}));
}); });
it('fetches discussions with filter and persistFilter false', () => { it('fetches discussions with filter and persistFilter false', () => {
actions.filterDiscussion({ dispatch }, { path, filter, persistFilter: false }); actions.filterDiscussion({ dispatch }, { path, filter, persistFilter: false });
expect(dispatch.calls.allArgs()).toEqual([ expect(dispatch.mock.calls).toEqual([
['setLoadingState', true], ['setLoadingState', true],
['fetchDiscussions', { path, filter, persistFilter: false }], ['fetchDiscussions', { path, filter, persistFilter: false }],
]); ]);
...@@ -902,7 +896,7 @@ describe('Actions Notes Store', () => { ...@@ -902,7 +896,7 @@ describe('Actions Notes Store', () => {
it('fetches discussions with filter and persistFilter true', () => { it('fetches discussions with filter and persistFilter true', () => {
actions.filterDiscussion({ dispatch }, { path, filter, persistFilter: true }); actions.filterDiscussion({ dispatch }, { path, filter, persistFilter: true });
expect(dispatch.calls.allArgs()).toEqual([ expect(dispatch.mock.calls).toEqual([
['setLoadingState', true], ['setLoadingState', true],
['fetchDiscussions', { path, filter, persistFilter: true }], ['fetchDiscussions', { path, filter, persistFilter: true }],
]); ]);
......
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);
});
});
});
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,
);
});
});
});
});
});
},
);
});
});
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 = { const deploymentMockData = {
id: 15, id: 15,
...@@ -29,4 +58,16 @@ const deploymentMockData = { ...@@ -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 };
...@@ -2,7 +2,6 @@ import { mount } from '@vue/test-utils'; ...@@ -2,7 +2,6 @@ import { mount } from '@vue/test-utils';
import DeploymentComponent from '~/vue_merge_request_widget/components/deployment/deployment.vue'; import DeploymentComponent from '~/vue_merge_request_widget/components/deployment/deployment.vue';
import DeploymentInfo from '~/vue_merge_request_widget/components/deployment/deployment_info.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 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 { import {
CREATED, CREATED,
RUNNING, RUNNING,
...@@ -10,15 +9,7 @@ import { ...@@ -10,15 +9,7 @@ import {
FAILED, FAILED,
CANCELED, CANCELED,
} from '~/vue_merge_request_widget/components/deployment/constants'; } from '~/vue_merge_request_widget/components/deployment/constants';
import deploymentMockData from './deployment_mock_data'; import { deploymentMockData, playDetails, retryDetails } 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,
};
describe('Deployment component', () => { describe('Deployment component', () => {
let wrapper; let wrapper;
...@@ -30,6 +21,7 @@ describe('Deployment component', () => { ...@@ -30,6 +21,7 @@ describe('Deployment component', () => {
} }
wrapper = mount(DeploymentComponent, { wrapper = mount(DeploymentComponent, {
...options, ...options,
provide: { glFeatures: { deployFromFooter: true } },
}); });
}; };
...@@ -53,28 +45,39 @@ describe('Deployment component', () => { ...@@ -53,28 +45,39 @@ describe('Deployment component', () => {
describe('status message and buttons', () => { describe('status message and buttons', () => {
const noActions = []; const noActions = [];
const noDetails = { isManual: false }; 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` describe.each`
status | previous | deploymentDetails | text | actionButtons status | previous | deploymentDetails | text | actionButtons
${CREATED} | ${true} | ${deployDetail} | ${'Can be manually deployed to'} | ${deployGroup} ${CREATED} | ${true} | ${deployDetail} | ${'Can be manually deployed to'} | ${manualDeployGroup}
${CREATED} | ${true} | ${noDetails} | ${'Will deploy to'} | ${deployGroup} ${CREATED} | ${true} | ${noDetails} | ${'Will deploy to'} | ${defaultGroup}
${CREATED} | ${false} | ${deployDetail} | ${'Can be manually deployed to'} | ${noActions} ${CREATED} | ${false} | ${deployDetail} | ${'Can be manually deployed to'} | ${noActions}
${CREATED} | ${false} | ${noDetails} | ${'Will deploy to'} | ${noActions} ${CREATED} | ${false} | ${noDetails} | ${'Will deploy to'} | ${noActions}
${RUNNING} | ${true} | ${deployDetail} | ${'Deploying to'} | ${deployGroup} ${RUNNING} | ${true} | ${deployDetail} | ${'Deploying to'} | ${defaultGroup}
${RUNNING} | ${true} | ${noDetails} | ${'Deploying to'} | ${deployGroup} ${RUNNING} | ${true} | ${noDetails} | ${'Deploying to'} | ${defaultGroup}
${RUNNING} | ${false} | ${deployDetail} | ${'Deploying to'} | ${noActions} ${RUNNING} | ${false} | ${deployDetail} | ${'Deploying to'} | ${noActions}
${RUNNING} | ${false} | ${noDetails} | ${'Deploying to'} | ${noActions} ${RUNNING} | ${false} | ${noDetails} | ${'Deploying to'} | ${noActions}
${SUCCESS} | ${true} | ${deployDetail} | ${'Deployed to'} | ${deployGroup} ${SUCCESS} | ${true} | ${deployDetail} | ${'Deployed to'} | ${defaultGroup}
${SUCCESS} | ${true} | ${noDetails} | ${'Deployed to'} | ${deployGroup} ${SUCCESS} | ${true} | ${noDetails} | ${'Deployed to'} | ${defaultGroup}
${SUCCESS} | ${false} | ${deployDetail} | ${'Deployed to'} | ${deployGroup} ${SUCCESS} | ${false} | ${deployDetail} | ${'Deployed to'} | ${defaultGroup}
${SUCCESS} | ${false} | ${noDetails} | ${'Deployed to'} | ${deployGroup} ${SUCCESS} | ${false} | ${noDetails} | ${'Deployed to'} | ${defaultGroup}
${FAILED} | ${true} | ${deployDetail} | ${'Failed to deploy to'} | ${deployGroup} ${FAILED} | ${true} | ${retryDetail} | ${'Failed to deploy to'} | ${manualRedeployGroup}
${FAILED} | ${true} | ${noDetails} | ${'Failed to deploy to'} | ${deployGroup} ${FAILED} | ${true} | ${noDetails} | ${'Failed to deploy to'} | ${defaultGroup}
${FAILED} | ${false} | ${deployDetail} | ${'Failed to deploy to'} | ${noActions} ${FAILED} | ${false} | ${retryDetail} | ${'Failed to deploy to'} | ${noActions}
${FAILED} | ${false} | ${noDetails} | ${'Failed to deploy to'} | ${noActions} ${FAILED} | ${false} | ${noDetails} | ${'Failed to deploy to'} | ${noActions}
${CANCELED} | ${true} | ${deployDetail} | ${'Canceled deployment to'} | ${deployGroup} ${CANCELED} | ${true} | ${deployDetail} | ${'Canceled deployment to'} | ${defaultGroup}
${CANCELED} | ${true} | ${noDetails} | ${'Canceled deployment to'} | ${deployGroup} ${CANCELED} | ${true} | ${noDetails} | ${'Canceled deployment to'} | ${defaultGroup}
${CANCELED} | ${false} | ${deployDetail} | ${'Canceled deployment to'} | ${noActions} ${CANCELED} | ${false} | ${deployDetail} | ${'Canceled deployment to'} | ${noActions}
${CANCELED} | ${false} | ${noDetails} | ${'Canceled deployment to'} | ${noActions} ${CANCELED} | ${false} | ${noDetails} | ${'Canceled deployment to'} | ${noActions}
`( `(
...@@ -112,7 +115,7 @@ describe('Deployment component', () => { ...@@ -112,7 +115,7 @@ describe('Deployment component', () => {
if (actionButtons.length > 0) { if (actionButtons.length > 0) {
describe('renders the expected button group', () => { describe('renders the expected button group', () => {
actionButtons.forEach(button => { actionButtons.forEach(button => {
it(`renders ${button.name}`, () => { it(`renders ${button}`, () => {
expect(wrapper.find(button).exists()).toBe(true); expect(wrapper.find(button).exists()).toBe(true);
}); });
}); });
...@@ -121,8 +124,8 @@ describe('Deployment component', () => { ...@@ -121,8 +124,8 @@ describe('Deployment component', () => {
if (actionButtons.length === 0) { if (actionButtons.length === 0) {
describe('does not render the button group', () => { describe('does not render the button group', () => {
[DeploymentViewButton, DeploymentStopButton].forEach(button => { defaultGroup.forEach(button => {
it(`does not render ${button.name}`, () => { it(`does not render ${button}`, () => {
expect(wrapper.find(button).exists()).toBe(false); expect(wrapper.find(button).exists()).toBe(false);
}); });
}); });
...@@ -144,10 +147,6 @@ describe('Deployment component', () => { ...@@ -144,10 +147,6 @@ describe('Deployment component', () => {
describe('hasExternalUrls', () => { describe('hasExternalUrls', () => {
describe('when deployment has both external_url_formatted and external_url', () => { 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', () => { it('should render the View Button', () => {
expect(wrapper.find(DeploymentViewButton).exists()).toBe(true); expect(wrapper.find(DeploymentViewButton).exists()).toBe(true);
}); });
...@@ -163,10 +162,6 @@ describe('Deployment component', () => { ...@@ -163,10 +162,6 @@ describe('Deployment component', () => {
}); });
}); });
it('should return false', () => {
expect(wrapper.vm.hasExternalUrls).toEqual(false);
});
it('should not render the View Button', () => { it('should not render the View Button', () => {
expect(wrapper.find(DeploymentViewButton).exists()).toBe(false); expect(wrapper.find(DeploymentViewButton).exists()).toBe(false);
}); });
...@@ -182,10 +177,6 @@ describe('Deployment component', () => { ...@@ -182,10 +177,6 @@ describe('Deployment component', () => {
}); });
}); });
it('should return false', () => {
expect(wrapper.vm.hasExternalUrls).toEqual(false);
});
it('should not render the View Button', () => { it('should not render the View Button', () => {
expect(wrapper.find(DeploymentViewButton).exists()).toBe(false); expect(wrapper.find(DeploymentViewButton).exists()).toBe(false);
}); });
......
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import DeploymentViewButton from '~/vue_merge_request_widget/components/deployment/deployment_view_button.vue'; 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 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 = { const appButtonText = {
text: 'View app', text: 'View app',
......
// eslint-disable-next-line import/prefer-default-export export * from '../../frontend/notes/helpers.js';
export const resetStore = store => {
store.replaceState({
notes: [],
targetNoteHash: null,
lastFetchedAt: null,
notesData: {},
userData: {},
noteableData: {},
});
};
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();
});
});
});
});
...@@ -195,6 +195,39 @@ describe Gitlab::Danger::CommitLinter do ...@@ -195,6 +195,39 @@ describe Gitlab::Danger::CommitLinter do
end end
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 context 'when subject ends with a period' do
let(:commit_message) { 'A B C.' } let(:commit_message) { 'A B C.' }
......
...@@ -273,6 +273,7 @@ MergeRequest::Metrics: ...@@ -273,6 +273,7 @@ MergeRequest::Metrics:
- modified_paths_size - modified_paths_size
- commits_count - commits_count
- first_approved_at - first_approved_at
- first_reassigned_at
Ci::Pipeline: Ci::Pipeline:
- id - id
- project_id - project_id
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment