diff --git a/app/models/environment.rb b/app/models/environment.rb index 5278efd71d2b008de8d132f118b6e2b8f2a5c395..a7f4156fc2ef53a71eef7ec82ee2e9b415cc1d48 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -19,7 +19,7 @@ class Environment < ActiveRecord::Base allow_nil: true, addressable_url: true - delegate :stop_action, to: :last_deployment, allow_nil: true + delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true scope :available, -> { with_state(:available) } scope :stopped, -> { with_state(:stopped) } @@ -99,4 +99,12 @@ class Environment < ActiveRecord::Base stop stop_action.play(current_user) end + + def actions_for(environment) + return [] unless manual_actions + + manual_actions.select do |action| + action.expanded_environment_name == environment + end + end end diff --git a/changelogs/unreleased/chatops-deploy-command.yml b/changelogs/unreleased/chatops-deploy-command.yml new file mode 100644 index 0000000000000000000000000000000000000000..1e5a3e8df1568eebc74bfa2668c5cde26693e560 --- /dev/null +++ b/changelogs/unreleased/chatops-deploy-command.yml @@ -0,0 +1,4 @@ +--- +title: Add deployment command to ChatOps +merge_request: 7619 +author: diff --git a/lib/gitlab/chat_commands/command.rb b/lib/gitlab/chat_commands/command.rb index 5f131703d40ea8c52765f9504e8231d867c6ac3d..0ec358debc7d4df00f5d1733b95e64f990f4a93c 100644 --- a/lib/gitlab/chat_commands/command.rb +++ b/lib/gitlab/chat_commands/command.rb @@ -4,6 +4,7 @@ module Gitlab COMMANDS = [ Gitlab::ChatCommands::IssueShow, Gitlab::ChatCommands::IssueCreate, + Gitlab::ChatCommands::Deploy, ].freeze def execute diff --git a/lib/gitlab/chat_commands/deploy.rb b/lib/gitlab/chat_commands/deploy.rb new file mode 100644 index 0000000000000000000000000000000000000000..c0b93cca68c1016b846a17f4dcab2ff8bf1325b2 --- /dev/null +++ b/lib/gitlab/chat_commands/deploy.rb @@ -0,0 +1,44 @@ +module Gitlab + module ChatCommands + class Deploy < BaseCommand + def self.match(text) + /\Adeploy\s+(?<from>.*)\s+to+\s+(?<to>.*)\z/.match(text) + end + + def self.help_message + 'deploy <environment> to <target-environment>' + end + + def self.available?(project) + project.builds_enabled? + end + + def self.allowed?(project, user) + can?(user, :create_deployment, project) + end + + def execute(match) + from = match[:from] + to = match[:to] + + actions = find_actions(from, to) + return unless actions.present? + + if actions.one? + actions.first.play(current_user) + else + Result.new(:error, 'Too many actions defined') + end + end + + private + + def find_actions(from, to) + environment = project.environments.find_by(name: from) + return unless environment + + environment.actions_for(to).select(&:starts_environment?) + end + end + end +end diff --git a/lib/gitlab/chat_commands/result.rb b/lib/gitlab/chat_commands/result.rb new file mode 100644 index 0000000000000000000000000000000000000000..324d7ef43a3bc30d5165770d326fe7447699b195 --- /dev/null +++ b/lib/gitlab/chat_commands/result.rb @@ -0,0 +1,5 @@ +module Gitlab + module ChatCommands + Result = Struct.new(:type, :message) + end +end diff --git a/lib/mattermost/presenter.rb b/lib/mattermost/presenter.rb index bfbb089eb02d4bce220ad9639ca06f453e4f20a7..6b12081575da9b7d4a9e83ac76715018af008130 100644 --- a/lib/mattermost/presenter.rb +++ b/lib/mattermost/presenter.rb @@ -24,20 +24,22 @@ module Mattermost end end - def present(resource) - return not_found unless resource - - if resource.respond_to?(:count) - if resource.count > 1 - return multiple_resources(resource) - elsif resource.count == 0 - return not_found + def present(subject) + return not_found unless subject + + if subject.is_a?(Gitlab::ChatCommands::Result) + show_result(subject) + elsif subject.respond_to?(:count) + if subject.many? + multiple_resources(subject) + elsif subject.none? + not_found else - resource = resource.first + single_resource(subject) end + else + single_resource(subject) end - - single_resource(resource) end def access_denied @@ -46,6 +48,10 @@ module Mattermost private + def show_result(result) + ephemeral_response(result.message) + end + def not_found ephemeral_response("404 not found! GitLab couldn't find what you were looking for! :boom:") end @@ -54,7 +60,7 @@ module Mattermost return error(resource) if resource.errors.any? || !resource.persisted? message = "### #{title(resource)}" - message << "\n\n#{resource.description}" if resource.description + message << "\n\n#{resource.description}" if resource.try(:description) in_channel_response(message) end @@ -74,7 +80,10 @@ module Mattermost end def title(resource) - "[#{resource.to_reference} #{resource.title}](#{url(resource)})" + reference = resource.try(:to_reference) || resource.try(:id) + title = resource.try(:title) || resource.try(:name) + + "[#{reference} #{title}](#{url(resource)})" end def header_with_list(header, items) diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 0c93bbdfe26ebfb2a0ca980f86700a93aaff09e5..eb20bd7dd583012a58ab0fcf0b5f82764db52ac8 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -55,6 +55,12 @@ FactoryGirl.define do self.when 'manual' end + trait :teardown_environment do + options do + { environment: { action: 'stop' } } + end + end + trait :allowed_to_fail do allow_failure true end diff --git a/spec/lib/gitlab/chat_commands/command_spec.rb b/spec/lib/gitlab/chat_commands/command_spec.rb index 8cedbb0240f2df389114544df82673f79150e269..924b4b7b10130f2a3b03cc581a6bfe80cb258ab0 100644 --- a/spec/lib/gitlab/chat_commands/command_spec.rb +++ b/spec/lib/gitlab/chat_commands/command_spec.rb @@ -4,9 +4,9 @@ describe Gitlab::ChatCommands::Command, service: true do let(:project) { create(:empty_project) } let(:user) { create(:user) } - subject { described_class.new(project, user, params).execute } - describe '#execute' do + subject { described_class.new(project, user, params).execute } + context 'when no command is available' do let(:params) { { text: 'issue show 1' } } let(:project) { create(:project, has_external_issue_tracker: true) } @@ -51,5 +51,44 @@ describe Gitlab::ChatCommands::Command, service: true do expect(subject[:text]).to match(/\/issues\/\d+/) end end + + context 'when trying to do deployment' do + let(:params) { { text: 'deploy staging to production' } } + let!(:build) { create(:ci_build, project: project) } + let!(:staging) { create(:environment, name: 'staging', project: project) } + let!(:deployment) { create(:deployment, environment: staging, deployable: build) } + let!(:manual) do + create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'first', environment: 'production') + end + + context 'and user can not create deployment' do + it 'returns action' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to start_with('Whoops! That action is not allowed') + end + end + + context 'and user does have deployment permission' do + before do + project.team << [user, :developer] + end + + it 'returns action' do + expect(subject[:text]).to include(manual.name) + expect(subject[:response_type]).to be(:in_channel) + end + + context 'when duplicate action exists' do + let!(:manual2) do + create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'second', environment: 'production') + end + + it 'returns error' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to include('Too many actions defined') + end + end + end + end end end diff --git a/spec/lib/gitlab/chat_commands/deploy_spec.rb b/spec/lib/gitlab/chat_commands/deploy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..26741367e632fd38d296ec32b4663ed710b5ba8c --- /dev/null +++ b/spec/lib/gitlab/chat_commands/deploy_spec.rb @@ -0,0 +1,76 @@ +require 'spec_helper' + +describe Gitlab::ChatCommands::Deploy, service: true do + describe '#execute' do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + let(:regex_match) { described_class.match('deploy staging to production') } + + before do + project.team << [user, :master] + end + + subject do + described_class.new(project, user).execute(regex_match) + end + + context 'if no environment is defined' do + it 'returns nil' do + expect(subject).to be_nil + end + end + + context 'with environment' do + let!(:staging) { create(:environment, name: 'staging', project: project) } + let!(:build) { create(:ci_build, project: project) } + let!(:deployment) { create(:deployment, environment: staging, deployable: build) } + + context 'without actions' do + it 'returns nil' do + expect(subject).to be_nil + end + end + + context 'with action' do + let!(:manual1) do + create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'first', environment: 'production') + end + + it 'returns action' do + expect(subject).to eq(manual1) + end + + context 'when duplicate action exists' do + let!(:manual2) do + create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'second', environment: 'production') + end + + it 'returns error' do + expect(subject.message).to eq('Too many actions defined') + end + end + + context 'when teardown action exists' do + let!(:teardown) do + create(:ci_build, :manual, :teardown_environment, + project: project, pipeline: build.pipeline, + name: 'teardown', environment: 'production') + end + + it 'returns error' do + expect(subject).to eq(manual1) + end + end + end + end + end + + describe 'self.match' do + it 'matches the environment' do + match = described_class.match('deploy staging to production') + + expect(match[:from]).to eq('staging') + expect(match[:to]).to eq('production') + end + end +end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 60bbe3fcd728f6b9464884c53c5dc0d7485fb345..d06665197db701869276fb5adf20f7e7157ddd78 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -9,6 +9,7 @@ describe Environment, models: true do it { is_expected.to delegate_method(:last_deployment).to(:deployments).as(:last) } it { is_expected.to delegate_method(:stop_action).to(:last_deployment) } + it { is_expected.to delegate_method(:manual_actions).to(:last_deployment) } it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) } @@ -187,4 +188,15 @@ describe Environment, models: true do it { is_expected.to be false } end end + + describe '#actions_for' do + let(:deployment) { create(:deployment, environment: environment) } + let(:pipeline) { deployment.deployable.pipeline } + let!(:review_action) { create(:ci_build, :manual, name: 'review-apps', pipeline: pipeline, environment: 'review/$CI_BUILD_REF_NAME' )} + let!(:production_action) { create(:ci_build, :manual, name: 'production', pipeline: pipeline, environment: 'production' )} + + it 'returns a list of actions with matching environment' do + expect(environment.actions_for('review/master')).to contain_exactly(review_action) + end + end end