diff options
39 files changed, 1277 insertions, 3 deletions
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index f0ae516a2f8..eb15347b4e1 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -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 diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb new file mode 100644 index 00000000000..8d37500fec5 --- /dev/null +++ b/app/models/ci/pipeline_chat_data.rb @@ -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 diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index 2994aaae4aa..4be4fdb1ff2 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -22,6 +22,7 @@ module Ci schedule: 4, api: 5, external: 6, + chat: 8, merge_request: 10 } end diff --git a/app/models/project_services/slack_slash_commands_service.rb b/app/models/project_services/slack_slash_commands_service.rb index 6c82e088231..6a454070fe2 100644 --- a/app/models/project_services/slack_slash_commands_service.rb +++ b/app/models/project_services/slack_slash_commands_service.rb @@ -22,6 +22,10 @@ class SlackSlashCommandsService < SlashCommandsService end end + def chat_responder + ::Gitlab::Chat::Responder::Slack + end + private def format(text) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 354e53a367c..c4f69175de3 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -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 diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 410411b1294..d0fc130b04f 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -101,6 +101,7 @@ - authorized_projects - background_migration +- chat_notification - create_gpg_signature - delete_merged_branches - delete_user diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index ae853ec9316..adc38226405 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -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 diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb new file mode 100644 index 00000000000..25a306e94d8 --- /dev/null +++ b/app/workers/chat_notification_worker.rb @@ -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 diff --git a/changelogs/unreleased/move_chatops_to_core.yml b/changelogs/unreleased/move_chatops_to_core.yml new file mode 100644 index 00000000000..7a75efedfa8 --- /dev/null +++ b/changelogs/unreleased/move_chatops_to_core.yml @@ -0,0 +1,5 @@ +--- +title: Move ChatOps to Core +merge_request: 24780 +author: +type: changed diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 1e094c03171..90cd787d5ac 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -86,3 +86,4 @@ - [delete_stored_files, 1] - [remote_mirror_notification, 2] - [import_issues_csv, 2] + - [chat_notification, 2] diff --git a/lib/gitlab/chat.rb b/lib/gitlab/chat.rb new file mode 100644 index 00000000000..23d4fb36b66 --- /dev/null +++ b/lib/gitlab/chat.rb @@ -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 diff --git a/lib/gitlab/chat/command.rb b/lib/gitlab/chat/command.rb new file mode 100644 index 00000000000..49b7dcf4bbe --- /dev/null +++ b/lib/gitlab/chat/command.rb @@ -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 diff --git a/lib/gitlab/chat/output.rb b/lib/gitlab/chat/output.rb new file mode 100644 index 00000000000..411b1555a7d --- /dev/null +++ b/lib/gitlab/chat/output.rb @@ -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 diff --git a/lib/gitlab/chat/responder.rb b/lib/gitlab/chat/responder.rb new file mode 100644 index 00000000000..6267fbc20e2 --- /dev/null +++ b/lib/gitlab/chat/responder.rb @@ -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 diff --git a/lib/gitlab/chat/responder/base.rb b/lib/gitlab/chat/responder/base.rb new file mode 100644 index 00000000000..f1ad0e36793 --- /dev/null +++ b/lib/gitlab/chat/responder/base.rb @@ -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 diff --git a/lib/gitlab/chat/responder/slack.rb b/lib/gitlab/chat/responder/slack.rb new file mode 100644 index 00000000000..0cf02c92a67 --- /dev/null +++ b/lib/gitlab/chat/responder/slack.rb @@ -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 diff --git a/lib/gitlab/ci/pipeline/chain/command.rb b/lib/gitlab/ci/pipeline/chain/command.rb index e62d547d862..e4ed1424865 100644 --- a/lib/gitlab/ci/pipeline/chain/command.rb +++ b/lib/gitlab/ci/pipeline/chain/command.rb @@ -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 diff --git a/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb b/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb index 0f687a4ce9b..1e09b417311 100644 --- a/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb +++ b/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs.rb @@ -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? diff --git a/lib/gitlab/slash_commands/application_help.rb b/lib/gitlab/slash_commands/application_help.rb new file mode 100644 index 00000000000..0ea7554ba64 --- /dev/null +++ b/lib/gitlab/slash_commands/application_help.rb @@ -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 diff --git a/lib/gitlab/slash_commands/command.rb b/lib/gitlab/slash_commands/command.rb index 474c09b9c4d..7c963fcf38a 100644 --- a/lib/gitlab/slash_commands/command.rb +++ b/lib/gitlab/slash_commands/command.rb @@ -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 diff --git a/lib/gitlab/slash_commands/presenters/error.rb b/lib/gitlab/slash_commands/presenters/error.rb new file mode 100644 index 00000000000..442f8796338 --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/error.rb @@ -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 diff --git a/lib/gitlab/slash_commands/presenters/run.rb b/lib/gitlab/slash_commands/presenters/run.rb new file mode 100644 index 00000000000..c4bbc231464 --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/run.rb @@ -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 diff --git a/lib/gitlab/slash_commands/run.rb b/lib/gitlab/slash_commands/run.rb new file mode 100644 index 00000000000..10a545e28ac --- /dev/null +++ b/lib/gitlab/slash_commands/run.rb @@ -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 diff --git a/spec/lib/gitlab/chat/command_spec.rb b/spec/lib/gitlab/chat/command_spec.rb new file mode 100644 index 00000000000..46d23ab2b62 --- /dev/null +++ b/spec/lib/gitlab/chat/command_spec.rb @@ -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 diff --git a/spec/lib/gitlab/chat/output_spec.rb b/spec/lib/gitlab/chat/output_spec.rb new file mode 100644 index 00000000000..b179f9e9d0a --- /dev/null +++ b/spec/lib/gitlab/chat/output_spec.rb @@ -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 diff --git a/spec/lib/gitlab/chat/responder/base_spec.rb b/spec/lib/gitlab/chat/responder/base_spec.rb new file mode 100644 index 00000000000..7fa9bad9d38 --- /dev/null +++ b/spec/lib/gitlab/chat/responder/base_spec.rb @@ -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 diff --git a/spec/lib/gitlab/chat/responder/slack_spec.rb b/spec/lib/gitlab/chat/responder/slack_spec.rb new file mode 100644 index 00000000000..a1553232b32 --- /dev/null +++ b/spec/lib/gitlab/chat/responder/slack_spec.rb @@ -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 diff --git a/spec/lib/gitlab/chat/responder_spec.rb b/spec/lib/gitlab/chat/responder_spec.rb new file mode 100644 index 00000000000..9893689cba9 --- /dev/null +++ b/spec/lib/gitlab/chat/responder_spec.rb @@ -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 diff --git a/spec/lib/gitlab/chat_spec.rb b/spec/lib/gitlab/chat_spec.rb new file mode 100644 index 00000000000..d61c4b36668 --- /dev/null +++ b/spec/lib/gitlab/chat_spec.rb @@ -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 diff --git a/spec/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs_spec.rb new file mode 100644 index 00000000000..7c1c016b4bb --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/chain/remove_unwanted_chat_jobs_spec.rb @@ -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 diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index c15b360b563..f2eccec4635 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -131,6 +131,7 @@ ci_pipelines: - merge_request - deployments - environments +- chat_data pipeline_variables: - pipeline stages: diff --git a/spec/lib/gitlab/slash_commands/application_help_spec.rb b/spec/lib/gitlab/slash_commands/application_help_spec.rb new file mode 100644 index 00000000000..b203a1ee79c --- /dev/null +++ b/spec/lib/gitlab/slash_commands/application_help_spec.rb @@ -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 diff --git a/spec/lib/gitlab/slash_commands/presenters/error_spec.rb b/spec/lib/gitlab/slash_commands/presenters/error_spec.rb new file mode 100644 index 00000000000..30ff81510c1 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/presenters/error_spec.rb @@ -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 diff --git a/spec/lib/gitlab/slash_commands/presenters/run_spec.rb b/spec/lib/gitlab/slash_commands/presenters/run_spec.rb new file mode 100644 index 00000000000..f3ab01ef6bb --- /dev/null +++ b/spec/lib/gitlab/slash_commands/presenters/run_spec.rb @@ -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 diff --git a/spec/lib/gitlab/slash_commands/run_spec.rb b/spec/lib/gitlab/slash_commands/run_spec.rb new file mode 100644 index 00000000000..900fae05719 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/run_spec.rb @@ -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 diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 460b5c8cd31..b9567ab4d65 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -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) } diff --git a/spec/models/project_services/slack_slash_commands_service_spec.rb b/spec/models/project_services/slack_slash_commands_service_spec.rb index 0d95f454819..5c4bce90ace 100644 --- a/spec/models/project_services/slack_slash_commands_service_spec.rb +++ b/spec/models/project_services/slack_slash_commands_service_spec.rb @@ -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 diff --git a/spec/workers/build_finished_worker_spec.rb b/spec/workers/build_finished_worker_spec.rb index acd8da11d8d..ccb26849e67 100644 --- a/spec/workers/build_finished_worker_spec.rb +++ b/spec/workers/build_finished_worker_spec.rb @@ -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 diff --git a/spec/workers/chat_notification_worker_spec.rb b/spec/workers/chat_notification_worker_spec.rb new file mode 100644 index 00000000000..91695674f5d --- /dev/null +++ b/spec/workers/chat_notification_worker_spec.rb @@ -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 |