Commit dc6921bd authored by Z.J. van de Weg's avatar Z.J. van de Weg

Chat Commands have presenters

This improves the styling and readability of the code. This is supported
by both Mattermost and Slack.
parent b525aff6
......@@ -28,20 +28,24 @@ def fields
end
def trigger(params)
return unless valid_token?(params[:token])
return access_presenter unless valid_token?(params[:token])
user = find_chat_user(params)
unless user
if user
Gitlab::ChatCommands::Command.new(project, user, params).execute
else
url = authorize_chat_name_url(params)
return presenter.authorize_chat_name(url)
access_presenter(url).authorize
end
Gitlab::ChatCommands::Command.new(project, user,
params).execute
end
private
def access_presenter(url = nil)
Gitlab::ChatCommands::Presenters::Access.new(url)
end
def find_chat_user(params)
ChatNames::FindUserService.new(self, params).execute
end
......@@ -49,8 +53,4 @@ def find_chat_user(params)
def authorize_chat_name_url(params)
ChatNames::AuthorizeUserService.new(self, params).execute
end
def presenter
Gitlab::ChatCommands::Presenter.new
end
end
......@@ -42,10 +42,6 @@ def initialize(project, user, params = {})
def find_by_iid(iid)
collection.find_by(iid: iid)
end
def presenter
Gitlab::ChatCommands::Presenter.new
end
end
end
end
......@@ -13,9 +13,9 @@ def execute
if command
if command.allowed?(project, current_user)
present command.new(project, current_user, params).execute(match)
command.new(project, current_user, params).execute(match)
else
access_denied
Gitlab::ChatCommands::Presenters::Access.new.access_denied
end
else
help(help_messages)
......@@ -25,7 +25,7 @@ def execute
def match_command
match = nil
service = available_commands.find do |klass|
match = klass.match(command)
match = klass.match(params[:text])
end
[service, match]
......@@ -42,22 +42,6 @@ def available_commands
klass.available?(project)
end
end
def command
params[:text]
end
def help(messages)
presenter.help(messages, params[:command])
end
def access_denied
presenter.access_denied
end
def present(resource)
presenter.present(resource)
end
end
end
end
module Gitlab
module ChatCommands
class Deploy < BaseCommand
include Gitlab::Routing.url_helpers
def self.match(text)
/\Adeploy\s+(?<from>\S+.*)\s+to+\s+(?<to>\S+.*)\z/.match(text)
end
......@@ -24,35 +22,29 @@ def execute(match)
to = match[:to]
actions = find_actions(from, to)
return unless actions.present?
if actions.one?
play!(from, to, actions.first)
if actions.none?
Gitlab::ChatCommands::Presenters::Deploy.new(nil).no_actions
elsif actions.one?
action = play!(from, to, actions.first)
Gitlab::ChatCommands::Presenters::Deploy.new(action).present(from, to)
else
Result.new(:error, 'Too many actions defined')
Gitlab::ChatCommands::Presenters::Deploy.new(actions).too_many_actions
end
end
private
def play!(from, to, action)
new_action = action.play(current_user)
Result.new(:success, "Deployment from #{from} to #{to} started. Follow the progress: #{url(new_action)}.")
action.play(current_user)
end
def find_actions(from, to)
environment = project.environments.find_by(name: from)
return unless environment
return [] unless environment
environment.actions_for(to).select(&:starts_environment?)
end
def url(subject)
polymorphic_url(
[subject.project.namespace.becomes(Namespace), subject.project, subject]
)
end
end
end
end
......@@ -2,7 +2,7 @@ module Gitlab
module ChatCommands
class IssueCreate < IssueCommand
def self.match(text)
# we can not match \n with the dot by passing the m modifier as than
# we can not match \n with the dot by passing the m modifier as than
# the title and description are not seperated
/\Aissue\s+(new|create)\s+(?<title>[^\n]*)\n*(?<description>(.|\n)*)/.match(text)
end
......@@ -19,8 +19,24 @@ def execute(match)
title = match[:title]
description = match[:description].to_s.rstrip
issue = create_issue(title: title, description: description)
if issue.errors.any?
presenter(issue).display_errors
else
presenter(issue).present
end
end
private
def create_issue(title:, description:)
Issues::CreateService.new(project, current_user, title: title, description: description).execute
end
def presenter(issue)
Gitlab::ChatCommands::Presenters::ShowIssue.new(issue)
end
end
end
end
......@@ -10,7 +10,15 @@ def self.help_message
end
def execute(match)
collection.search(match[:query]).limit(QUERY_LIMIT)
issues = collection.search(match[:query]).limit(QUERY_LIMIT)
if issues.none?
Presenters::Access.new(issues).not_found
elsif issues.one?
Presenters::ShowIssue.new(issues.first).present
else
Presenters::ListIssues.new(issues).present
end
end
end
end
......
......@@ -10,7 +10,13 @@ def self.help_message
end
def execute(match)
find_by_iid(match[:iid])
issue = find_by_iid(match[:iid])
if issue
Gitlab::ChatCommands::Presenters::ShowIssue.new(issue).present
else
Gitlab::ChatCommands::Presenters::Access.new.not_found
end
end
end
end
......
module Gitlab
module ChatCommands
class Presenter
include Gitlab::Routing
def authorize_chat_name(url)
message = if url
":wave: Hi there! Before I do anything for you, please [connect your GitLab account](#{url})."
else
":sweat_smile: Couldn't identify you, nor can I autorize you!"
end
ephemeral_response(message)
end
def help(commands, trigger)
if commands.none?
ephemeral_response("No commands configured")
else
commands.map! { |command| "#{trigger} #{command}" }
message = header_with_list("Available commands", commands)
ephemeral_response(message)
end
end
def present(subject)
return not_found unless subject
if subject.is_a?(Gitlab::ChatCommands::Result)
show_result(subject)
elsif subject.respond_to?(:count)
if subject.none?
not_found
elsif subject.one?
single_resource(subject.first)
else
multiple_resources(subject)
end
else
single_resource(subject)
end
end
def access_denied
ephemeral_response("Whoops! That action is not allowed. This incident will be [reported](https://xkcd.com/838/).")
end
private
def show_result(result)
case result.type
when :success
in_channel_response(result.message)
else
ephemeral_response(result.message)
end
end
def not_found
ephemeral_response("404 not found! GitLab couldn't find what you were looking for! :boom:")
end
def single_resource(resource)
return error(resource) if resource.errors.any? || !resource.persisted?
message = "#{title(resource)}:"
message << "\n\n#{resource.description}" if resource.try(:description)
in_channel_response(message)
end
def multiple_resources(resources)
titles = resources.map { |resource| title(resource) }
message = header_with_list("Multiple results were found:", titles)
ephemeral_response(message)
end
def error(resource)
message = header_with_list("The action was not successful, because:", resource.errors.messages)
ephemeral_response(message)
end
def title(resource)
reference = resource.try(:to_reference) || resource.try(:id)
title = resource.try(:title) || resource.try(:name)
"[#{reference} #{title}](#{url(resource)})"
end
def header_with_list(header, items)
message = [header]
items.each do |item|
message << "- #{item}"
end
message.join("\n")
end
def url(resource)
url_for(
[
resource.project.namespace.becomes(Namespace),
resource.project,
resource
]
)
end
def ephemeral_response(message)
{
response_type: :ephemeral,
text: message,
status: 200
}
end
def in_channel_response(message)
{
response_type: :in_channel,
text: message,
status: 200
}
end
end
end
end
module Gitlab::ChatCommands::Presenters
class Access < Gitlab::ChatCommands::Presenters::Base
def access_denied
ephemeral_response(text: "Whoops! This action is not allowed. This incident will be [reported](https://xkcd.com/838/).")
end
def not_found
ephemeral_response(text: "404 not found! GitLab couldn't find what you were looking for! :boom:")
end
def authorize
message =
if @resource
":wave: Hi there! Before I do anything for you, please [connect your GitLab account](#{@resource})."
else
":sweat_smile: Couldn't identify you, nor can I autorize you!"
end
ephemeral_response(text: message)
end
end
end
module Gitlab::ChatCommands::Presenters
class Base
include Gitlab::Routing.url_helpers
def initialize(resource = nil)
@resource = resource
end
def display_errors
message = header_with_list("The action was not successful, because:", @resource.errors.full_messages)
ephemeral_response(text: message)
end
private
def header_with_list(header, items)
message = [header]
items.each do |item|
message << "- #{item}"
end
message.join("\n")
end
def ephemeral_response(message)
response = {
response_type: :ephemeral,
status: 200
}.merge(message)
format_response(response)
end
def in_channel_response(message)
response = {
response_type: :in_channel,
status: 200
}.merge(message)
format_response(response)
end
def format_response(response)
response[:text] = format(response[:text]) if response.has_key?(:text)
if response.has_key?(:attachments)
response[:attachments].each do |attachment|
attachment[:pretext] = format(attachment[:pretext]) if attachment[:pretext]
attachment[:text] = format(attachment[:text]) if attachment[:text]
end
end
response
end
# Convert Markdown to slacks format
def format(string)
Slack::Notifier::LinkFormatter.format(string)
end
def resource_url
url_for(
[
@resource.project.namespace.becomes(Namespace),
@resource.project,
@resource
]
)
end
end
end
module Gitlab::ChatCommands::Presenters
class Deploy < Gitlab::ChatCommands::Presenters::Base
def present(from, to)
message = "Deployment started from #{from} to #{to}. [Follow its progress](#{resource_url})."
in_channel_response(text: message)
end
def no_actions
ephemeral_response(text: "No action found to be executed")
end
def too_many_actions
ephemeral_response(text: "Too many actions defined")
end
private
def resource_url
polymorphic_url(
[ @resource.project.namespace.becomes(Namespace), @resource.project, @resource]
)
end
end
end
module Gitlab::ChatCommands::Presenters
class Issuable < Gitlab::ChatCommands::Presenters::Base
private
def project
@resource.project
end
def author
@resource.author
end
def fields
[
{
title: "Assignee",
value: @resource.assignee ? @resource.assignee.name : "_None_",
short: true
},
{
title: "Milestone",
value: @resource.milestone ? @resource.milestone.title : "_None_",
short: true
},
{
title: "Labels",
value: @resource.labels.any? ? @resource.label_names : "_None_",
short: true
}
]
end
end
end
module Gitlab::ChatCommands::Presenters
class ListIssues < Gitlab::ChatCommands::Presenters::Base
def present
ephemeral_response(text: "Here are the issues I found:", attachments: attachments)
end
private
def attachments
@resource.map do |issue|
state = issue.open? ? "Open" : "Closed"
{
fallback: "Issue #{issue.to_reference}: #{issue.title}",
color: "#d22852",
text: "[#{issue.to_reference}](#{url_for([namespace, project, issue])}) · #{issue.title} (#{state})",
mrkdwn_in: [
"text"
]
}
end
end
def project
@project ||= @resource.first.project
end
def namespace
@namespace ||= project.namespace.becomes(Namespace)
end
end
end
module Gitlab::ChatCommands::Presenters
class ShowIssue < Gitlab::ChatCommands::Presenters::Issuable
def present
in_channel_response(show_issue)
end
private
def show_issue
{
attachments: [
{
title: @resource.title,
title_link: resource_url,
author_name: author.name,
author_icon: author.avatar_url,
fallback: "#{@resource.to_reference}: #{@resource.title}",
text: text,
fields: fields,
mrkdwn_in: [
:title,
:text
]
}
]
}
end
def text
message = ""
message << ":+1: #{@resource.upvotes} " unless @resource.upvotes.zero?
message << ":-1: #{@resource.downvotes} " unless @resource.downvotes.zero?
message << ":speech_balloon: #{@resource.user_notes_count}" unless @resource.user_notes_count.zero?
message
end
end
end
module Mattermost
class Error < StandardError; end
end
module Mattermost
class NoSessionError < Mattermost::Error
def message
'No session could be set up, is Mattermost configured with Single Sign On?'
end
end
class ConnectionError < Mattermost::Error; end
# This class' prime objective is to obtain a session token on a Mattermost
# instance with SSO configured where this GitLab instance is the provider.
#
# The process depends on OAuth, but skips a step in the authentication cycle.
# For example, usually a user would click the 'login in GitLab' button on
# Mattermost, which would yield a 302 status code and redirects you to GitLab
# to approve the use of your account on Mattermost. Which would trigger a
# callback so Mattermost knows this request is approved and gets the required
# data to create the user account etc.
#
# This class however skips the button click, and also the approval phase to
# speed up the process and keep it without manual action and get a session
# going.
class Session
include Doorkeeper::Helpers::Controller
include HTTParty
LEASE_TIMEOUT = 60
base_uri Settings.mattermost.host
attr_accessor :current_resource_owner, :token
def initialize(current_user)
@current_resource_owner = current_user
end
def with_session
with_lease do
raise Mattermost::NoSessionError unless create
begin
yield self
rescue Errno::ECONNREFUSED
raise Mattermost::NoSessionError
ensure
destroy
end