Commit f9b9ad66 authored by Kamil Trzciński's avatar Kamil Trzciński Committed by Alejandro Rodríguez

Merge branch 'zj-slash-commands-mattermost' into 'master'

Slash command for mattermost

Closes  #22540

## Does this MR meet the acceptance criteria?

- [x] [Changelog entry](https://docs.gitlab.com/ce/development/changelog.html) added
- [ ] [Documentation created/updated](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/development/doc_styleguide.md)
- Tests
  - [x] Added for this feature/bug
  - [x] All builds are passing
- [x] Conform by the [merge request performance guides](http://docs.gitlab.com/ce/development/merge_request_performance_guidelines.html)
- [x] Conform by the [style guides](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#style-guides)
- [x] Branch has no merge conflicts with `master` (if it does - rebase it please)


See merge request !7438
parent 23b02b77
......@@ -28,6 +28,8 @@ class Projects::ServicesController < Projects::ApplicationController
end
def test
return render_404 unless @service.can_test?
data = @service.test_data(project, current_user)
outcome = @service.test(data)
......
......@@ -6,4 +6,8 @@ module TriggersHelper
"#{Settings.gitlab.url}/api/v3/projects/#{project_id}/ref/#{ref}/trigger/builds"
end
end
def service_trigger_url(service)
"#{Settings.gitlab.url}/api/v3/projects/#{service.project_id}/services/#{service.to_param}/trigger"
end
end
......@@ -24,7 +24,9 @@ class Project < ActiveRecord::Base
cache_markdown_field :description, pipeline: :description
delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?, to: :project_feature, allow_nil: true
delegate :feature_available?, :builds_enabled?, :wiki_enabled?,
:merge_requests_enabled?, :issues_enabled?, to: :project_feature,
allow_nil: true
default_value_for :archived, false
default_value_for :visibility_level, gitlab_config_features.visibility_level
......@@ -76,6 +78,7 @@ class Project < ActiveRecord::Base
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
has_many :boards, before_add: :validate_board_limit, dependent: :destroy
has_many :chat_services
# Project services
has_one :campfire_service, dependent: :destroy
......@@ -90,6 +93,7 @@ class Project < ActiveRecord::Base
has_one :assembla_service, dependent: :destroy
has_one :asana_service, dependent: :destroy
has_one :gemnasium_service, dependent: :destroy
has_one :mattermost_slash_commands_service, dependent: :destroy
has_one :slack_service, dependent: :destroy
has_one :buildkite_service, dependent: :destroy
has_one :bamboo_service, dependent: :destroy
......
......@@ -60,6 +60,10 @@ class ProjectFeature < ActiveRecord::Base
merge_requests_access_level > DISABLED
end
def issues_enabled?
issues_access_level > DISABLED
end
private
# Validates builds and merge requests access level
......
# Base class for Chat services
# This class is not meant to be used directly, but only to inherrit from.
class ChatService < Service
default_value_for :category, 'chat'
has_many :chat_names, foreign_key: :service_id
def valid_token?(token)
self.respond_to?(:token) &&
self.token.present? &&
ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token)
end
def supported_events
[]
end
def trigger(params)
raise NotImplementedError
end
end
class MattermostSlashCommandsService < ChatService
include TriggersHelper
prop_accessor :token
def can_test?
false
end
def title
'Mattermost Command'
end
def description
"Perform common operations on GitLab in Mattermost"
end
def to_param
'mattermost_slash_commands'
end
def help
"This service allows you to use slash commands with your Mattermost installation.<br/>
To setup this Service you need to create a new <b>Slash commands</b> in your Mattermost integration panel.<br/>
<br/>
Create integration with URL #{service_trigger_url(self)} and enter the token below."
end
def fields
[
{ type: 'text', name: 'token', placeholder: '' }
]
end
def trigger(params)
return nil unless valid_token?(params[:token])
user = find_chat_user(params)
unless user
url = authorize_chat_name_url(params)
return Mattermost::Presenter.authorize_chat_name(url)
end
Gitlab::ChatCommands::Command.new(project, user, params).execute
end
private
def find_chat_user(params)
ChatNames::FindUserService.new(self, params).execute
end
def authorize_chat_name_url(params)
ChatNames::AuthorizeUserService.new(self, params).execute
end
end
......@@ -202,7 +202,6 @@ class Service < ActiveRecord::Base
bamboo
buildkite
builds_email
pipelines_email
bugzilla
campfire
custom_issue_tracker
......@@ -214,6 +213,8 @@ class Service < ActiveRecord::Base
hipchat
irker
jira
mattermost_slash_commands
pipelines_email
pivotaltracker
pushover
redmine
......
......@@ -10,7 +10,8 @@
.col-sm-10
= form.check_box :active
.form-group
- if @service.supported_events.present?
.form-group
= form.label :url, "Trigger", class: 'control-label'
.col-sm-10
......
---
title: Added Mattermost slash command
merge_request: 7438
author:
......@@ -85,8 +85,8 @@ module API
end
end
def project_service
@project_service ||= user_project.find_or_initialize_service(params[:service_slug].underscore)
def project_service(project = user_project)
@project_service ||= project.find_or_initialize_service(params[:service_slug].underscore)
@project_service || not_found!("Service")
end
......
module API
# Projects API
class Services < Grape::API
resource :projects do
before { authenticate! }
before { authorize_admin_project }
resource :projects do
# Set <service_slug> service for project
#
# Example Request:
......@@ -59,5 +59,28 @@ module API
present project_service, with: Entities::ProjectService, include_passwords: current_user.is_admin?
end
end
resource :projects do
desc 'Trigger a slash command' do
detail 'Added in GitLab 8.13'
end
post ':id/services/:service_slug/trigger' do
project = Project.find_with_namespace(params[:id]) || Project.find_by(id: params[:id])
# This is not accurate, but done to prevent leakage of the project names
not_found!('Service') unless project
service = project_service(project)
result = service.try(:active?) && service.try(:trigger, params)
if result
status result[:status] || 200
present result
else
not_found!('Service')
end
end
end
end
end
module Gitlab
module ChatCommands
class BaseCommand
QUERY_LIMIT = 5
def self.match(_text)
raise NotImplementedError
end
def self.help_message
raise NotImplementedError
end
def self.available?(_project)
raise NotImplementedError
end
def self.allowed?(_user, _ability)
true
end
def self.can?(object, action, subject)
Ability.allowed?(object, action, subject)
end
def execute(_)
raise NotImplementedError
end
def collection
raise NotImplementedError
end
attr_accessor :project, :current_user, :params
def initialize(project, user, params = {})
@project, @current_user, @params = project, user, params.dup
end
private
def find_by_iid(iid)
resource = collection.find_by(iid: iid)
readable?(resource) ? resource : nil
end
end
end
end
module Gitlab
module ChatCommands
class Command < BaseCommand
COMMANDS = [
Gitlab::ChatCommands::IssueShow,
Gitlab::ChatCommands::IssueCreate,
].freeze
def execute
command, match = match_command
if command
if command.allowed?(project, current_user)
present command.new(project, current_user, params).execute(match)
else
access_denied
end
else
help(help_messages)
end
end
private
def match_command
match = nil
service = available_commands.find do |klass|
match = klass.match(command)
end
[service, match]
end
def help_messages
available_commands.map(&:help_message)
end
def available_commands
COMMANDS.select do |klass|
klass.available?(project)
end
end
def command
params[:text]
end
def help(messages)
Mattermost::Presenter.help(messages, params[:command])
end
def access_denied
Mattermost::Presenter.access_denied
end
def present(resource)
Mattermost::Presenter.present(resource)
end
end
end
end
module Gitlab
module ChatCommands
class IssueCommand < BaseCommand
def self.available?(project)
project.issues_enabled? && project.default_issues_tracker?
end
def collection
project.issues
end
def readable?(issue)
self.class.can?(current_user, :read_issue, issue)
end
end
end
end
module Gitlab
module ChatCommands
class IssueCreate < IssueCommand
def self.match(text)
/\Aissue\s+create\s+(?<title>[^\n]*)\n*(?<description>.*)\z/.match(text)
end
def self.help_message
'issue create <title>\n<description>'
end
def self.allowed?(project, user)
can?(user, :create_issue, project)
end
def execute(match)
title = match[:title]
description = match[:description]
Issues::CreateService.new(project, current_user, title: title, description: description).execute
end
end
end
end
module Gitlab
module ChatCommands
class IssueShow < IssueCommand
def self.match(text)
/\Aissue\s+show\s+(?<iid>\d+)/.match(text)
end
def self.help_message
"issue show <id>"
end
def execute(match)
find_by_iid(match[:iid])
end
end
end
end
module Mattermost
class Presenter
class << self
include Gitlab::Routing.url_helpers
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(resource)
return not_found unless resource
if resource.respond_to?(:count)
if resource.count > 1
return multiple_resources(resource)
elsif resource.count == 0
return not_found
else
resource = resource.first
end
end
single_resource(resource)
end
def access_denied
ephemeral_response("Whoops! That action is not allowed. This incident will be [reported](https://xkcd.com/838/).")
end
private
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.description
in_channel_response(message)
end
def multiple_resources(resources)
resources.map! { |resource| title(resource) }
message = header_with_list("Multiple results were found:", resources)
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)
"[#{resource.to_reference} #{resource.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
require 'spec_helper'
describe Gitlab::ChatCommands::Command, service: true do
let(:project) { create(:empty_project) }
let(:user) { create(:user) }
subject { described_class.new(project, user, params).execute }
describe '#execute' do
context 'when no command is available' do
let(:params) { { text: 'issue show 1' } }
let(:project) { create(:project, has_external_issue_tracker: true) }
it 'displays 404 messages' do
expect(subject[:response_type]).to be(:ephemeral)
expect(subject[:text]).to start_with('404 not found')
end
end
context 'when an unknown command is triggered' do
let(:params) { { command: '/gitlab', text: "unknown command 123" } }
it 'displays the help message' do
expect(subject[:response_type]).to be(:ephemeral)
expect(subject[:text]).to start_with('Available commands')
expect(subject[:text]).to match('/gitlab issue show')
end
end
context 'the user can not create an issue' do
let(:params) { { text: "issue create my new issue" } }
it 'rejects the actions' do
expect(subject[:response_type]).to be(:ephemeral)
expect(subject[:text]).to start_with('Whoops! That action is not allowed')
end
end
context 'issue is successfully created' do
let(:params) { { text: "issue create my new issue" } }
before do
project.team << [user, :master]
end
it 'presents the issue' do
expect(subject[:text]).to match("my new issue")
end
it 'shows a link to the new issue' do
expect(subject[:text]).to match(/\/issues\/\d+/)
end
end
end
end
require 'spec_helper'
describe Gitlab::ChatCommands::IssueCreate, service: true do
describe '#execute' do
let(:project) { create(:empty_project) }
let(:user) { create(:user) }
let(:regex_match) { described_class.match("issue create bird is the word") }
before do
project.team << [user, :master]
end
subject do
described_class.new(project, user).execute(regex_match)
end
context 'without description' do
it 'creates the issue' do
expect { subject }.to change { project.issues.count }.by(1)
expect(subject.title).to eq('bird is the word')
end
end
context 'with description' do
let(:description) { "Surfin bird" }
let(:regex_match) { described_class.match("issue create bird is the word\n#{description}") }
it 'creates the issue with description' do
subject
expect(Issue.last.description).to eq(description)
end
end
end
describe '.match' do
it 'matches the title without description' do
match = described_class.match("issue create my title")
expect(match[:title]).to eq('my title')
expect(match[:description]).to eq("")
end
it 'matches the title with description' do
match = described_class.match("issue create my title\n\ndescription")
expect(match[:title]).to eq('my title')
expect(match[:description]).to eq('description')
end
end
end
require 'spec_helper'
describe Gitlab::ChatCommands::IssueShow, service: true do
describe '#execute' do
let(:issue) { create(:issue) }
let(:project) { issue.project }
let(:user) { issue.author }
let(:regex_match) { described_class.match("issue show #{issue.iid}") }
before do
project.team << [user, :master]
end
subject do
described_class.new(project, user).execute(regex_match)
end
context 'the issue exists' do
it 'returns the issue' do
expect(subject.iid).to be issue.iid
end
end
context 'the issue does not exist' do
let(:regex_match) { described_class.match("issue show 2343242") }
it "returns nil" do
expect(subject).to be_nil
end
end
end
describe 'self.match' do
it 'matches the iid' do
match = described_class.match("issue show 123")
expect(match[:iid]).to eq("123")
end
end
end
......@@ -116,6 +116,7 @@ project:
- base_tags
- tag_taggings
- tags
- chat_services
- creator
- group
- namespace
......@@ -127,6 +128,7 @@ project:
- emails_on_push_service
- builds_email_service
- pipelines_email_service
- mattermost_slash_commands_service
- irker_service
- pivotaltracker_service
- hipchat_service
......
require 'spec_helper'
describe ChatService, models: true do
describe "Associations" do
it { is_expected.to have_many :chat_names }
end
describe '#valid_token?' do
subject { described_class.new }
it 'is false as it has no token' do
expect(subject.valid_token?('wer')).to be_falsey
end
end
end
require 'spec_helper'
describe MattermostSlashCommandsService, models: true do
describe "Associations" do
it { is_expected.to respond_to :token }
end
describe '#valid_token?' do
subject { described_class.new }
context 'when the token is empty' do
it 'is false' do
expect(subject.valid_token?('wer')).to be_falsey
end
end
context 'when there is a token' do
before do
subject.token = '123'
end
it 'accepts equal tokens' do
expect(subject.valid_token?('123')).to be_truthy
end
end
end
describe '#trigger' do
subject { described_class.new }
context 'no token is passed' do
let(:params) { Hash.new }
it 'returns nil' do
expect(subject.trigger(params)).to be_nil
end
end
context 'with a token passed' do
let(:project) { create(:empty_project) }
let(:params) { { token: 'token' } }
before do
allow(subject).to receive(:token).and_return('token')
end
context 'no user can be found' do
context 'when no url can be generated' do
it 'responds with the authorize url' do
response = subject.trigger(params)
expect(response[:response_type]).to eq :ephemeral
expect(response[:text]).to start_with ":sweat_smile: Couldn't identify you"
end
end
context 'when an auth url can be generated' do
let(:params) do
{
team_domain: 'http://domain.tld',
team_id: 'T3423423',
user_id: 'U234234',
user_name: 'mepmep',
token: 'token'
}
end
let(:service) do
project.create_mattermost_slash_commands_service(
properties: { token: 'token' }
)
end
it 'generates the url' do
response = service.trigger(params)
expect(response[:text]).to start_with(':wave: Hi there!')
end
end
end
context 'when the user is authenticated' do
let!(:chat_name) { create(:chat_name, service: service) }
let(:service) do
project.create_mattermost_slash_commands_service(
properties: { token: 'token' }
)
end
let(:params) { { token: 'token', team_id: chat_name.team_id, user_id: chat_name.chat_id } }
it 'triggers the command' do
expect_any_instance_of(Gitlab::ChatCommands::Command).to receive(:execute)
service.trigger(params)
end
end
end
end
end
......@@ -20,6 +20,7 @@ describe Project, models: true do
it { is_expected.to have_many(:deploy_keys) }
it { is_expected.to have_many(:hooks).dependent(:destroy) }
it { is_expected.to have_many(:protected_branches).dependent(:destroy) }
it { is_expected.to have_many(:chat_services) }
it { is_expected.to have_one(:forked_project_link).dependent(:destroy) }
it { is_expected.to have_one(:slack_service).dependent(:destroy) }
it { is_expected.to have_one(:pushover_service).dependent(:destroy) }
......@@ -35,6 +36,7 @@ describe Project, models: true do
it { is_expected.to have_one(:hipchat_service).dependent(:destroy) }
it { is_expected.to have_one(:flowdock_service).dependent(:destroy) }
it { is_expected.to have_one(:assembla_service).dependent(:destroy) }
it { is_expected.to have_one(:mattermost_slash_commands_service).dependent(:destroy) }
it { is_expected.to have_one(:gemnasium_service).dependent(:destroy) }
it { is_expected.to have_one(:buildkite_service).dependent(:destroy) }
it { is_expected.to have_one(:bamboo_service).dependent(:destroy) }
......
......@@ -88,4 +88,61 @@ describe API::API, api: true do
end
end
end
describe 'POST /projects/:id/services/:slug/trigger' do
let!(:project) { create(:empty_project) }
let(:service_name) { 'mattermost_slash_commands' }
context 'no service is available' do
it 'returns a not found message' do
post api("/projects/#{project.id}/services/idonotexist/trigger")
expect(response).to have_http_status(404)
expect(json_response["message"]).to eq("404 Service Not Found")
end
end
context 'the service exists' do
let(:params) { { token: 'token' } }
context 'the service is not active' do
let!(:inactive_service) do
project.create_mattermost_slash_commands_service(
active: false,
properties: { token: 'token' }
)
end
it 'when the service is inactive' do
post api("/projects/#{project.id}/services/mattermost_slash_commands/trigger")
expect(response).to have_http_status(404)
end
end
context 'the service is active' do
let!(:active_service) do
project.create_mattermost_slash_commands_service(
active: true,
properties: { token: 'token' }
)
end
it 'retusn status 200' do
post api("/projects/#{project.id}/services/mattermost_slash_commands/trigger"), params
expect(response).to have_http_status(200)
end
end
context 'when the project can not be found' do
it 'returns a generic 404' do
post api("/projects/404/services/mattermost_slash_commands/trigger"), params
expect(response).to have_http_status(404)
expect(json_response["message"]).to eq("404 Service Not Found")
end
end
end
end
end
......@@ -13,7 +13,7 @@ describe ChatNames::FindUserService, services: true do
context 'when existing user is requested' do
let(:params) { { team_id: chat_name.team_id, user_id: chat_name.chat_id } }
it 'returns existing user' do
it 'returns the existing user' do
is_expected.to eq(user)
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment