Skip to content
Snippets Groups Projects
Commit 2d19b1ad authored by James Fargher's avatar James Fargher Committed by Robert Speicher
Browse files

Move ChatOps to Core

ChatOps used to be in the Ultimate tier.
parent ee0a007f
No related branches found
No related tags found
No related merge requests found
Showing
with 437 additions and 3 deletions
Loading
Loading
@@ -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
Loading
Loading
# 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
Loading
Loading
@@ -22,6 +22,7 @@ module Ci
schedule: 4,
api: 5,
external: 6,
chat: 8,
merge_request: 10
}
end
Loading
Loading
Loading
Loading
@@ -22,6 +22,10 @@ class SlackSlashCommandsService < SlashCommandsService
end
end
 
def chat_responder
::Gitlab::Chat::Responder::Slack
end
private
 
def format(text)
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -101,6 +101,7 @@
 
- authorized_projects
- background_migration
- chat_notification
- create_gpg_signature
- delete_merged_branches
- delete_user
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
@@ -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
Loading
Loading
@@ -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
 
Loading
Loading
Loading
Loading
@@ -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?
Loading
Loading
# 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
Loading
Loading
@@ -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
 
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment