Merge branch 'move_chatops_to_core' into 'master'

Move ChatOps to Core

See merge request gitlab-org/gitlab-ce!24780
This commit is contained in:
Robert Speicher 2019-02-20 21:29:48 +00:00
commit 4f3147b0fd
39 changed files with 1277 additions and 3 deletions

View file

@ -47,6 +47,8 @@ module Ci
has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
has_one :chat_data, class_name: 'Ci::PipelineChatData'
accepts_nested_attributes_for :variables, reject_if: :persisted?
delegate :id, to: :project, prefix: true

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Ci
class PipelineChatData < ActiveRecord::Base
self.table_name = 'ci_pipeline_chat_data'
belongs_to :chat_name
validates :pipeline_id, presence: true
validates :chat_name_id, presence: true
validates :response_url, presence: true
end
end

View file

@ -22,6 +22,7 @@ module Ci
schedule: 4,
api: 5,
external: 6,
chat: 8,
merge_request: 10
}
end

View file

@ -22,6 +22,10 @@ class SlackSlashCommandsService < SlashCommandsService
end
end
def chat_responder
::Gitlab::Chat::Responder::Slack
end
private
def format(text)

View file

@ -36,6 +36,7 @@ module Ci
project: project,
current_user: current_user,
push_options: params[:push_options],
chat_data: params[:chat_data],
**extra_options(options))
sequence = Gitlab::Ci::Pipeline::Chain::Sequence

View file

@ -101,6 +101,7 @@
- authorized_projects
- background_migration
- chat_notification
- create_gpg_signature
- delete_merged_branches
- delete_user

View file

@ -30,5 +30,6 @@ class BuildFinishedWorker
# We execute these async as these are independent operations.
BuildHooksWorker.perform_async(build.id)
ArchiveTraceWorker.perform_async(build.id)
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
end
end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
class ChatNotificationWorker
include ApplicationWorker
RESCHEDULE_INTERVAL = 2.seconds
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
Ci::Build.find_by(id: build_id).try do |build|
send_response(build)
end
rescue Gitlab::Chat::Output::MissingBuildSectionError
# The creation of traces and sections appears to be eventually consistent.
# As a result it's possible for us to run the above code before the trace
# sections are present. To better handle such cases we'll just reschedule
# the job instead of producing an error.
self.class.perform_in(RESCHEDULE_INTERVAL, build_id)
end
# rubocop: enable CodeReuse/ActiveRecord
def send_response(build)
Gitlab::Chat::Responder.responder_for(build).try do |responder|
if build.success?
output = Gitlab::Chat::Output.new(build)
responder.success(output.to_s)
else
responder.failure
end
end
end
end

View file

@ -0,0 +1,5 @@
---
title: Move ChatOps to Core
merge_request: 24780
author:
type: changed

View file

@ -86,3 +86,4 @@
- [delete_stored_files, 1]
- [remote_mirror_notification, 2]
- [import_issues_csv, 2]
- [chat_notification, 2]

10
lib/gitlab/chat.rb Normal file
View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
module Gitlab
module Chat
# Returns `true` if Chatops is available for the current instance.
def self.available?
::Feature.enabled?(:chatops, default_enabled: true)
end
end
end

View file

@ -0,0 +1,94 @@
# frozen_string_literal: true
module Gitlab
module Chat
# Class for scheduling chat pipelines.
#
# A Command takes care of creating a `Ci::Pipeline` with all the data
# necessary to execute a chat command. This includes data such as the chat
# data (e.g. the response URL) and any environment variables that should be
# exposed to the chat command.
class Command
include Utils::StrongMemoize
attr_reader :project, :chat_name, :name, :arguments, :response_url,
:channel
# project - The Project to schedule the command for.
# chat_name - The ChatName belonging to the user that scheduled the
# command.
# name - The name of the chat command to run.
# arguments - The arguments (as a String) to pass to the command.
# channel - The channel the message was sent from.
# response_url - The URL to send the response back to.
def initialize(project:, chat_name:, name:, arguments:, channel:, response_url:)
@project = project
@chat_name = chat_name
@name = name
@arguments = arguments
@channel = channel
@response_url = response_url
end
# Tries to create a new pipeline.
#
# This method will return a pipeline that _may_ be persisted, or `nil` if
# the pipeline could not be created.
def try_create_pipeline
return unless valid?
create_pipeline
end
def create_pipeline
service = ::Ci::CreatePipelineService.new(
project,
chat_name.user,
ref: branch,
sha: commit,
chat_data: {
chat_name_id: chat_name.id,
command: name,
arguments: arguments,
response_url: response_url
}
)
service.execute(:chat) do |pipeline|
build_environment_variables(pipeline)
build_chat_data(pipeline)
end
end
# pipeline - The `Ci::Pipeline` to create the environment variables for.
def build_environment_variables(pipeline)
pipeline.variables.build(
[{ key: 'CHAT_INPUT', value: arguments },
{ key: 'CHAT_CHANNEL', value: channel }]
)
end
# pipeline - The `Ci::Pipeline` to create the chat data for.
def build_chat_data(pipeline)
pipeline.build_chat_data(
chat_name_id: chat_name.id,
response_url: response_url
)
end
def valid?
branch && commit
end
def branch
strong_memoize(:branch) { project.default_branch }
end
def commit
strong_memoize(:commit) do
project.commit(branch)&.id if branch
end
end
end
end
end

93
lib/gitlab/chat/output.rb Normal file
View file

@ -0,0 +1,93 @@
# frozen_string_literal: true
module Gitlab
module Chat
# Class for gathering and formatting the output of a `Ci::Build`.
class Output
attr_reader :build
MissingBuildSectionError = Class.new(StandardError)
# The primary trace section to look for.
PRIMARY_SECTION = 'chat_reply'
# The backup trace section in case the primary one could not be found.
FALLBACK_SECTION = 'build_script'
# build - The `Ci::Build` to obtain the output from.
def initialize(build)
@build = build
end
# Returns a `String` containing the output of the build.
#
# The output _does not_ include the command that was executed.
def to_s
offset, length = read_offset_and_length
trace.read do |stream|
stream.seek(offset)
output = stream
.stream
.read(length)
.force_encoding(Encoding.default_external)
without_executed_command_line(output)
end
end
# Returns the offset to seek to and the number of bytes to read relative
# to the offset.
def read_offset_and_length
section = find_build_trace_section(PRIMARY_SECTION) ||
find_build_trace_section(FALLBACK_SECTION)
unless section
raise(
MissingBuildSectionError,
"The build_script trace section could not be found for build #{build.id}"
)
end
length = section[:byte_end] - section[:byte_start]
[section[:byte_start], length]
end
# Removes the line containing the executed command from the build output.
#
# output - A `String` containing the output of a trace section.
def without_executed_command_line(output)
# If `output.split("\n")` produces an empty Array then the slicing that
# follows it will produce a nil. For example:
#
# "\n".split("\n") # => []
# "\n".split("\n")[1..-1] # => nil
#
# To work around this we only "join" if we're given an Array.
if (converted = output.split("\n")[1..-1])
converted.join("\n")
else
''
end
end
# Returns the trace section for the given name, or `nil` if the section
# could not be found.
#
# name - The name of the trace section to find.
def find_build_trace_section(name)
trace_sections.find { |s| s[:name] == name }
end
def trace_sections
@trace_sections ||= trace.extract_sections
end
def trace
@trace ||= build.trace
end
end
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
module Gitlab
module Chat
module Responder
# Returns an instance of the responder to use for generating chat
# responses.
#
# This method will return `nil` if no formatter is available for the given
# build.
#
# build - A `Ci::Build` that executed a chat command.
def self.responder_for(build)
service = build.pipeline.chat_data&.chat_name&.service
if (responder = service.try(:chat_responder))
responder.new(build)
end
end
end
end
end

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
module Gitlab
module Chat
module Responder
class Base
attr_reader :build
# build - The `Ci::Build` that was executed.
def initialize(build)
@build = build
end
def pipeline
build.pipeline
end
def project
pipeline.project
end
def success(*)
raise NotImplementedError, 'You must implement #success(output)'
end
def failure
raise NotImplementedError, 'You must implement #failure'
end
def send_response(output)
raise NotImplementedError, 'You must implement #send_response(output)'
end
def scheduled_output
raise NotImplementedError, 'You must implement #scheduled_output'
end
end
end
end
end

View file

@ -0,0 +1,80 @@
# frozen_string_literal: true
module Gitlab
module Chat
module Responder
class Slack < Responder::Base
SUCCESS_COLOR = '#B3ED8E'
FAILURE_COLOR = '#FF5640'
RESPONSE_TYPE = :in_channel
# Slack breaks messages apart if they're around 4 KB in size. We use a
# slightly smaller limit here to account for user mentions.
MESSAGE_SIZE_LIMIT = 3.5.kilobytes
# Sends a response back to Slack
#
# output - The output to send back to Slack, as a Hash.
def send_response(output)
Gitlab::HTTP.post(
pipeline.chat_data.response_url,
{
headers: { Accept: 'application/json' },
body: output.to_json
}
)
end
# Sends the output for a build that completed successfully.
#
# output - The output produced by the chat command.
def success(output)
return if output.empty?
send_response(
text: message_text(limit_output(output)),
response_type: RESPONSE_TYPE
)
end
# Sends the output for a build that failed.
def failure
send_response(
text: message_text("<#{build_url}|Sorry, the build failed!>"),
response_type: RESPONSE_TYPE
)
end
# Returns the output to send back after a command has been scheduled.
def scheduled_output
# We return an empty message so that Slack still shows the input
# command, without polluting the channel with standard "The job has
# been scheduled" (or similar) responses.
{ text: '' }
end
private
def limit_output(output)
if output.bytesize <= MESSAGE_SIZE_LIMIT
output
else
"<#{build_url}|The output is too large to be sent back directly!>"
end
end
def mention_user
"<@#{pipeline.chat_data.chat_name.chat_id}>"
end
def message_text(output)
"#{mention_user}: #{output}"
end
def build_url
::Gitlab::Routing.url_helpers.project_build_url(project, build)
end
end
end
end
end

View file

@ -10,7 +10,8 @@ module Gitlab
:origin_ref, :checkout_sha, :after_sha, :before_sha,
:trigger_request, :schedule, :merge_request,
:ignore_skip_ci, :save_incompleted,
:seeds_block, :variables_attributes, :push_options
:seeds_block, :variables_attributes, :push_options,
:chat_data
) do
include Gitlab::Utils::StrongMemoize

View file

@ -6,7 +6,13 @@ module Gitlab
module Chain
class RemoveUnwantedChatJobs < Chain::Base
def perform!
# to be overriden in EE
return unless pipeline.config_processor && pipeline.chat?
# When scheduling a chat pipeline we only want to run the build
# that matches the chat command.
pipeline.config_processor.jobs.select! do |name, _|
name.to_s == command.chat_data[:command].to_s
end
end
def break?

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Gitlab
module SlashCommands
class ApplicationHelp < BaseCommand
def initialize(params)
@params = params
end
def execute
Gitlab::SlashCommands::Presenters::Help.new(commands).present(trigger, params[:text])
end
private
def trigger
"#{params[:command]} [project name or alias]"
end
def commands
Gitlab::SlashCommands::Command.commands
end
end
end
end

View file

@ -9,7 +9,8 @@ module Gitlab
Gitlab::SlashCommands::IssueNew,
Gitlab::SlashCommands::IssueSearch,
Gitlab::SlashCommands::IssueMove,
Gitlab::SlashCommands::Deploy
Gitlab::SlashCommands::Deploy,
Gitlab::SlashCommands::Run
]
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Gitlab
module SlashCommands
module Presenters
class Error < Presenters::Base
def initialize(message)
@message = message
end
def message
ephemeral_response(text: @message)
end
end
end
end
end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
module Gitlab
module SlashCommands
module Presenters
class Run < Presenters::Base
# rubocop: disable CodeReuse/ActiveRecord
def present(pipeline)
build = pipeline.builds.take
if build && (responder = Chat::Responder.responder_for(build))
in_channel_response(responder.scheduled_output)
else
unsupported_chat_service
end
end
# rubocop: enable CodeReuse/ActiveRecord
def unsupported_chat_service
ephemeral_response(text: 'Sorry, this chat service is currently not supported by GitLab ChatOps.')
end
def failed_to_schedule(command)
ephemeral_response(
text: 'The command could not be scheduled. Make sure that your ' \
'project has a .gitlab-ci.yml that defines a job with the ' \
"name #{command.inspect}"
)
end
end
end
end
end

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
module Gitlab
module SlashCommands
# Slash command for triggering chatops jobs.
class Run < BaseCommand
def self.match(text)
/\Arun\s+(?<command>\S+)(\s+(?<arguments>.+))?\z/.match(text)
end
def self.help_message
'run <command> <arguments>'
end
def self.available?(project)
Chat.available? && project.builds_enabled?
end
def self.allowed?(project, user)
can?(user, :create_pipeline, project)
end
def execute(match)
command = Chat::Command.new(
project: project,
chat_name: chat_name,
name: match[:command],
arguments: match[:arguments],
channel: params[:channel_id],
response_url: params[:response_url]
)
presenter = Gitlab::SlashCommands::Presenters::Run.new
pipeline = command.try_create_pipeline
if pipeline&.persisted?
presenter.present(pipeline)
else
presenter.failed_to_schedule(command.name)
end
end
end
end
end

View file

@ -0,0 +1,77 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Chat::Command do
let(:chat_name) { create(:chat_name) }
let(:command) do
described_class.new(
project: project,
chat_name: chat_name,
name: 'spinach',
arguments: 'foo',
channel: '123',
response_url: 'http://example.com'
)
end
describe '#try_create_pipeline' do
let(:project) { create(:project) }
it 'returns nil when the command is not valid' do
expect(command)
.to receive(:valid?)
.and_return(false)
expect(command.try_create_pipeline).to be_nil
end
it 'tries to create the pipeline when a command is valid' do
expect(command)
.to receive(:valid?)
.and_return(true)
expect(command)
.to receive(:create_pipeline)
command.try_create_pipeline
end
end
describe '#create_pipeline' do
let(:project) { create(:project, :test_repo) }
let(:pipeline) { command.create_pipeline }
before do
stub_repository_ci_yaml_file(sha: project.commit.id)
project.add_developer(chat_name.user)
end
it 'creates the pipeline' do
expect(pipeline).to be_persisted
end
it 'creates the chat data for the pipeline' do
expect(pipeline.chat_data).to be_an_instance_of(Ci::PipelineChatData)
end
it 'stores the chat name ID in the chat data' do
expect(pipeline.chat_data.chat_name_id).to eq(chat_name.id)
end
it 'stores the response URL in the chat data' do
expect(pipeline.chat_data.response_url).to eq('http://example.com')
end
it 'creates the environment variables for the pipeline' do
vars = pipeline.variables.each_with_object({}) do |row, hash|
hash[row.key] = row.value
end
expect(vars['CHAT_INPUT']).to eq('foo')
expect(vars['CHAT_CHANNEL']).to eq('123')
end
end
end

View file

@ -0,0 +1,101 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Chat::Output do
let(:build) do
create(:ci_build, pipeline: create(:ci_pipeline, source: :chat))
end
let(:output) { described_class.new(build) }
describe '#to_s' do
it 'returns the build output as a String' do
trace = Gitlab::Ci::Trace.new(build)
trace.set("echo hello\nhello")
allow(build)
.to receive(:trace)
.and_return(trace)
allow(output)
.to receive(:read_offset_and_length)
.and_return([0, 13])
expect(output.to_s).to eq('he')
end
end
describe '#read_offset_and_length' do
context 'without the chat_reply trace section' do
it 'falls back to using the build_script trace section' do
expect(output)
.to receive(:find_build_trace_section)
.with('chat_reply')
.and_return(nil)
expect(output)
.to receive(:find_build_trace_section)
.with('build_script')
.and_return({ name: 'build_script', byte_start: 1, byte_end: 4 })
expect(output.read_offset_and_length).to eq([1, 3])
end
end
context 'without the build_script trace section' do
it 'raises MissingBuildSectionError' do
expect { output.read_offset_and_length }
.to raise_error(described_class::MissingBuildSectionError)
end
end
context 'with the chat_reply trace section' do
it 'returns the read offset and length as an Array' do
trace = Gitlab::Ci::Trace.new(build)
allow(build)
.to receive(:trace)
.and_return(trace)
allow(trace)
.to receive(:extract_sections)
.and_return([{ name: 'chat_reply', byte_start: 1, byte_end: 4 }])
expect(output.read_offset_and_length).to eq([1, 3])
end
end
end
describe '#without_executed_command_line' do
it 'returns the input without the first line' do
expect(output.without_executed_command_line("hello\nworld"))
.to eq('world')
end
it 'returns an empty String when the input is empty' do
expect(output.without_executed_command_line('')).to eq('')
end
it 'returns an empty String when the input consits of a single newline' do
expect(output.without_executed_command_line("\n")).to eq('')
end
end
describe '#find_build_trace_section' do
it 'returns nil when no section could be found' do
expect(output.find_build_trace_section('foo')).to be_nil
end
it 'returns the trace section when it could be found' do
section = { name: 'chat_reply', byte_start: 1, byte_end: 4 }
allow(output)
.to receive(:trace_sections)
.and_return([section])
expect(output.find_build_trace_section('chat_reply')).to eq(section)
end
end
end

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Chat::Responder::Base do
let(:project) { double(:project) }
let(:pipeline) { double(:pipeline, project: project) }
let(:build) { double(:build, pipeline: pipeline) }
let(:responder) { described_class.new(build) }
describe '#pipeline' do
it 'returns the pipeline' do
expect(responder.pipeline).to eq(pipeline)
end
end
describe '#project' do
it 'returns the project' do
expect(responder.project).to eq(project)
end
end
describe '#success' do
it 'raises NotImplementedError' do
expect { responder.success }.to raise_error(NotImplementedError)
end
end
describe '#failure' do
it 'raises NotImplementedError' do
expect { responder.failure }.to raise_error(NotImplementedError)
end
end
describe '#send_response' do
it 'raises NotImplementedError' do
expect { responder.send_response('hello') }
.to raise_error(NotImplementedError)
end
end
describe '#scheduled_output' do
it 'raises NotImplementedError' do
expect { responder.scheduled_output }
.to raise_error(NotImplementedError)
end
end
end

View file

@ -0,0 +1,77 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Chat::Responder::Slack do
let(:chat_name) { create(:chat_name, chat_id: 'U123') }
let(:pipeline) do
pipeline = create(:ci_pipeline)
pipeline.create_chat_data!(
response_url: 'http://example.com',
chat_name_id: chat_name.id
)
pipeline
end
let(:build) { create(:ci_build, pipeline: pipeline) }
let(:responder) { described_class.new(build) }
describe '#send_response' do
it 'sends a response back to Slack' do
expect(Gitlab::HTTP).to receive(:post).with(
'http://example.com',
{ headers: { Accept: 'application/json' }, body: 'hello'.to_json }
)
responder.send_response('hello')
end
end
describe '#success' do
it 'returns the output for a successful build' do
expect(responder)
.to receive(:send_response)
.with(hash_including(text: /<@U123>:.+hello/, response_type: :in_channel))
responder.success('hello')
end
it 'limits the output to a fixed size' do
expect(responder)
.to receive(:send_response)
.with(hash_including(text: /The output is too large/))
responder.success('a' * 4000)
end
it 'does not send a response if the output is empty' do
expect(responder).not_to receive(:send_response)
responder.success('')
end
end
describe '#failure' do
it 'returns the output for a failed build' do
expect(responder).to receive(:send_response).with(
hash_including(
text: /<@U123>:.+Sorry, the build failed!/,
response_type: :in_channel
)
)
responder.failure
end
end
describe '#scheduled_output' do
it 'returns the output for a scheduled build' do
output = responder.scheduled_output
expect(output).to eq({ text: '' })
end
end
end

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Chat::Responder do
describe '.responder_for' do
context 'using a regular build' do
it 'returns nil' do
build = create(:ci_build)
expect(described_class.responder_for(build)).to be_nil
end
end
context 'using a chat build' do
it 'returns the responder for the build' do
pipeline = create(:ci_pipeline)
build = create(:ci_build, pipeline: pipeline)
service = double(:service, chat_responder: Gitlab::Chat::Responder::Slack)
chat_name = double(:chat_name, service: service)
chat_data = double(:chat_data, chat_name: chat_name)
allow(pipeline)
.to receive(:chat_data)
.and_return(chat_data)
expect(described_class.responder_for(build))
.to be_an_instance_of(Gitlab::Chat::Responder::Slack)
end
end
end
end

View file

@ -0,0 +1,23 @@
require 'spec_helper'
describe Gitlab::Chat, :use_clean_rails_memory_store_caching do
describe '.available?' do
it 'returns true when the chatops feature is available' do
allow(Feature)
.to receive(:enabled?)
.with(:chatops, default_enabled: true)
.and_return(true)
expect(described_class).to be_available
end
it 'returns false when the chatops feature is not available' do
allow(Feature)
.to receive(:enabled?)
.with(:chatops, default_enabled: true)
.and_return(false)
expect(described_class).not_to be_available
end
end
end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs do
let(:project) { create(:project, :repository) }
let(:pipeline) do
build(:ci_pipeline_with_one_job, project: project, ref: 'master')
end
let(:command) do
double(:command, project: project, chat_data: { command: 'echo' })
end
describe '#perform!' do
it 'removes unwanted jobs for chat pipelines' do
allow(pipeline).to receive(:chat?).and_return(true)
pipeline.config_processor.jobs[:echo] = double(:job)
described_class.new(pipeline, command).perform!
expect(pipeline.config_processor.jobs.keys).to eq([:echo])
end
end
it 'does not remove any jobs for non-chat pipelines' do
described_class.new(pipeline, command).perform!
expect(pipeline.config_processor.jobs.keys).to eq([:rspec])
end
end

View file

@ -131,6 +131,7 @@ ci_pipelines:
- merge_request
- deployments
- environments
- chat_data
pipeline_variables:
- pipeline
stages:

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::SlashCommands::ApplicationHelp do
let(:params) { { command: '/gitlab', text: 'help' } }
describe '#execute' do
subject do
described_class.new(params).execute
end
it 'displays the help section' do
expect(subject[:response_type]).to be(:ephemeral)
expect(subject[:text]).to include('Available commands')
expect(subject[:text]).to include('/gitlab [project name or alias] issue show')
end
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::SlashCommands::Presenters::Error do
subject { described_class.new('Error').message }
it { is_expected.to be_a(Hash) }
it 'shows the error message' do
expect(subject[:response_type]).to be(:ephemeral)
expect(subject[:status]).to eq(200)
expect(subject[:text]).to eq('Error')
end
end

View file

@ -0,0 +1,79 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::SlashCommands::Presenters::Run do
let(:presenter) { described_class.new }
describe '#present' do
context 'when no builds are present' do
it 'returns an error' do
builds = double(:builds, take: nil)
pipeline = double(:pipeline, builds: builds)
expect(presenter)
.to receive(:unsupported_chat_service)
presenter.present(pipeline)
end
end
context 'when a responder could be found' do
it 'returns the output for a scheduled pipeline' do
responder = double(:responder, scheduled_output: 'hello')
build = double(:build)
builds = double(:builds, take: build)
pipeline = double(:pipeline, builds: builds)
allow(Gitlab::Chat::Responder)
.to receive(:responder_for)
.with(build)
.and_return(responder)
expect(presenter)
.to receive(:in_channel_response)
.with('hello')
presenter.present(pipeline)
end
end
context 'when a responder could not be found' do
it 'returns an error' do
build = double(:build)
builds = double(:builds, take: build)
pipeline = double(:pipeline, builds: builds)
allow(Gitlab::Chat::Responder)
.to receive(:responder_for)
.with(build)
.and_return(nil)
expect(presenter)
.to receive(:unsupported_chat_service)
presenter.present(pipeline)
end
end
end
describe '#unsupported_chat_service' do
it 'returns an ephemeral response' do
expect(presenter)
.to receive(:ephemeral_response)
.with(text: /Sorry, this chat service is currently not supported/)
presenter.unsupported_chat_service
end
end
describe '#failed_to_schedule' do
it 'returns an ephemeral response' do
expect(presenter)
.to receive(:ephemeral_response)
.with(text: /The command could not be scheduled/)
presenter.failed_to_schedule('foo')
end
end
end

View file

@ -0,0 +1,123 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::SlashCommands::Run do
describe '.available?' do
it 'returns true when builds are enabled for the project' do
project = double(:project, builds_enabled?: true)
allow(Gitlab::Chat)
.to receive(:available?)
.and_return(true)
expect(described_class.available?(project)).to eq(true)
end
it 'returns false when builds are disabled for the project' do
project = double(:project, builds_enabled?: false)
expect(described_class.available?(project)).to eq(false)
end
it 'returns false when chatops is not available' do
allow(Gitlab::Chat)
.to receive(:available?)
.and_return(false)
project = double(:project, builds_enabled?: true)
expect(described_class.available?(project)).to eq(false)
end
end
describe '.allowed?' do
it 'returns true when the user can create a pipeline' do
project = create(:project)
expect(described_class.allowed?(project, project.creator)).to eq(true)
end
it 'returns false when the user can not create a pipeline' do
project = create(:project)
user = create(:user)
expect(described_class.allowed?(project, user)).to eq(false)
end
end
describe '#execute' do
let(:chat_name) { create(:chat_name) }
let(:project) { create(:project) }
let(:command) do
described_class.new(project, chat_name, response_url: 'http://example.com')
end
context 'when a pipeline could not be scheduled' do
it 'returns an error' do
expect_any_instance_of(Gitlab::Chat::Command)
.to receive(:try_create_pipeline)
.and_return(nil)
expect_any_instance_of(Gitlab::SlashCommands::Presenters::Run)
.to receive(:failed_to_schedule)
.with('foo')
command.execute(command: 'foo', arguments: '')
end
end
context 'when a pipeline could be created but the chat service was not supported' do
it 'returns an error' do
build = double(:build)
pipeline = double(
:pipeline,
builds: double(:relation, take: build),
persisted?: true
)
expect_any_instance_of(Gitlab::Chat::Command)
.to receive(:try_create_pipeline)
.and_return(pipeline)
expect(Gitlab::Chat::Responder)
.to receive(:responder_for)
.with(build)
.and_return(nil)
expect_any_instance_of(Gitlab::SlashCommands::Presenters::Run)
.to receive(:unsupported_chat_service)
command.execute(command: 'foo', arguments: '')
end
end
context 'using a valid pipeline' do
it 'schedules the pipeline' do
responder = double(:responder, scheduled_output: 'hello')
build = double(:build)
pipeline = double(
:pipeline,
builds: double(:relation, take: build),
persisted?: true
)
expect_any_instance_of(Gitlab::Chat::Command)
.to receive(:try_create_pipeline)
.and_return(pipeline)
expect(Gitlab::Chat::Responder)
.to receive(:responder_for)
.with(build)
.and_return(responder)
expect_any_instance_of(Gitlab::SlashCommands::Presenters::Run)
.to receive(:in_channel_response)
.with(responder.scheduled_output)
command.execute(command: 'foo', arguments: '')
end
end
end
end

View file

@ -22,6 +22,7 @@ describe Ci::Pipeline, :mailer do
it { is_expected.to have_many(:builds) }
it { is_expected.to have_many(:auto_canceled_pipelines) }
it { is_expected.to have_many(:auto_canceled_jobs) }
it { is_expected.to have_one(:chat_data) }
it { is_expected.to validate_presence_of(:sha) }
it { is_expected.to validate_presence_of(:status) }

View file

@ -38,4 +38,11 @@ describe SlackSlashCommandsService do
end
end
end
describe '#chat_responder' do
it 'returns the responder to use for Slack' do
expect(described_class.new.chat_responder)
.to eq(Gitlab::Chat::Responder::Slack)
end
end
end

View file

@ -26,5 +26,24 @@ describe BuildFinishedWorker do
.not_to raise_error
end
end
it 'schedules a ChatNotification job for a chat build' do
build = create(:ci_build, :success, pipeline: create(:ci_pipeline, source: :chat))
expect(ChatNotificationWorker)
.to receive(:perform_async)
.with(build.id)
described_class.new.perform(build.id)
end
it 'does not schedule a ChatNotification job for a regular build' do
build = create(:ci_build, :success, pipeline: create(:ci_pipeline))
expect(ChatNotificationWorker)
.not_to receive(:perform_async)
described_class.new.perform(build.id)
end
end
end

View file

@ -0,0 +1,91 @@
# frozen_string_literal: true
require 'spec_helper'
describe ChatNotificationWorker do
let(:worker) { described_class.new }
let(:chat_build) do
create(:ci_build, pipeline: create(:ci_pipeline, source: :chat))
end
describe '#perform' do
it 'does nothing when the build no longer exists' do
expect(worker).not_to receive(:send_response)
worker.perform(-1)
end
it 'sends a response for an existing build' do
expect(worker)
.to receive(:send_response)
.with(an_instance_of(Ci::Build))
worker.perform(chat_build.id)
end
it 'reschedules the job if the trace sections could not be found' do
expect(worker)
.to receive(:send_response)
.and_raise(Gitlab::Chat::Output::MissingBuildSectionError)
expect(described_class)
.to receive(:perform_in)
.with(described_class::RESCHEDULE_INTERVAL, chat_build.id)
worker.perform(chat_build.id)
end
end
describe '#send_response' do
context 'when a responder could not be found' do
it 'does nothing' do
expect(Gitlab::Chat::Responder)
.to receive(:responder_for)
.with(chat_build)
.and_return(nil)
expect(worker.send_response(chat_build)).to be_nil
end
end
context 'when a responder could be found' do
let(:responder) { double(:responder) }
before do
allow(Gitlab::Chat::Responder)
.to receive(:responder_for)
.with(chat_build)
.and_return(responder)
end
it 'sends the response for a succeeded build' do
output = double(:output, to_s: 'this is the build output')
expect(chat_build)
.to receive(:success?)
.and_return(true)
expect(responder)
.to receive(:success)
.with(an_instance_of(String))
expect(Gitlab::Chat::Output)
.to receive(:new)
.with(chat_build)
.and_return(output)
worker.send_response(chat_build)
end
it 'sends the response for a failed build' do
expect(chat_build)
.to receive(:success?)
.and_return(false)
expect(responder).to receive(:failure)
worker.send_response(chat_build)
end
end
end
end