Commit 2d19b1ad authored by James Fargher's avatar James Fargher Committed by Robert Speicher

Move ChatOps to Core

ChatOps used to be in the Ultimate tier.
parent ee0a007f
......@@ -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
......
# 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
......@@ -22,6 +22,7 @@ module Ci
schedule: 4,
api: 5,
external: 6,
chat: 8,
merge_request: 10
}
end
......
......@@ -22,6 +22,10 @@ class SlackSlashCommandsService < SlashCommandsService
end
end
def chat_responder
::Gitlab::Chat::Responder::Slack
end
private
def format(text)
......
......@@ -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
......
......@@ -101,6 +101,7 @@
- authorized_projects
- background_migration
- chat_notification
- create_gpg_signature
- delete_merged_branches
- delete_user
......
......@@ -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
# 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
---
title: Move ChatOps to Core
merge_request: 24780
author:
type: changed
......@@ -86,3 +86,4 @@
- [delete_stored_files, 1]
- [remote_mirror_notification, 2]
- [import_issues_csv, 2]
- [chat_notification, 2]
# 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
# 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
# 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
# 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
# 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
# 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
......@@ -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
......
......@@ -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?
......
# 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
......@@ -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
......
# 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
# 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
# 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
# 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
# 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