Commit 251fa4c1 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'slack_app' into 'master'

Slack application service

See merge request !2259
parents da230da9 5978f535
......@@ -181,7 +181,11 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:mirror_max_delay,
:mirror_max_capacity,
:mirror_capacity_threshold,
:authorized_keys_enabled
:authorized_keys_enabled,
:slack_app_enabled,
:slack_app_id,
:slack_app_secret,
:slack_app_verification_token
]
end
end
module Projects
module Settings
class SlacksController < Projects::ApplicationController
before_action :handle_oauth_error, only: :slack_auth
before_action :authorize_admin_project!
def slack_auth
result = Projects::SlackApplicationInstallService.new(project, current_user, params).execute
if result[:status] == :error
flash[:alert] = result[:message]
end
redirect_to_service_page
end
def destroy
service = project.gitlab_slack_application_service
service.slack_integration.destroy
redirect_to_service_page
end
private
def redirect_to_service_page
redirect_to edit_project_service_path(
project,
project.gitlab_slack_application_service || project.build_gitlab_slack_application_service
)
end
def handle_oauth_error
if params[:error] == 'access_denied'
flash[:alert] = 'Access denied'
redirect_to_service_page
end
end
end
end
end
......@@ -2,6 +2,8 @@ module AuthHelper
PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze
FORM_BASED_PROVIDERS = [/\Aldap/, 'kerberos', 'crowd'].freeze
delegate :slack_app_id, to: :current_application_settings
def ldap_enabled?
Gitlab::LDAP::Config.enabled?
end
......@@ -72,5 +74,9 @@ module AuthHelper
%w(saml cas3).exclude?(provider.to_s)
end
def slack_redirect_uri(project)
slack_auth_project_settings_slack_url(project)
end
extend self
end
......@@ -266,7 +266,11 @@ class ApplicationSetting < ActiveRecord::Base
two_factor_grace_period: 48,
user_default_external: false,
polling_interval_multiplier: 1,
usage_ping_enabled: Settings.gitlab['usage_ping_enabled']
usage_ping_enabled: Settings.gitlab['usage_ping_enabled'],
slack_app_enabled: false,
slack_app_id: nil,
slack_app_secret: nil,
slack_app_verification_token: nil
}
end
......
......@@ -89,6 +89,7 @@ class Project < ActiveRecord::Base
# Project services
has_one :campfire_service
has_one :drone_ci_service
has_one :gitlab_slack_application_service
has_one :emails_on_push_service
has_one :pipelines_email_service
has_one :irker_service
......
class GitlabSlackApplicationService < Service
default_value_for :category, 'chat'
has_one :slack_integration, foreign_key: :service_id
def self.supported_events
%w()
end
def show_active_box?
false
end
def editable?
false
end
def update_active_status
update(active: !!slack_integration)
end
def can_test?
false
end
def title
'Slack application'
end
def description
'Use the GitLab Slack application for this project'
end
def self.to_param
'gitlab_slack_application'
end
def fields
[]
end
end
......@@ -51,6 +51,14 @@ class Service < ActiveRecord::Base
active
end
def show_active_box?
true
end
def editable?
true
end
def template?
template
end
......@@ -237,7 +245,6 @@ class Service < ActiveRecord::Base
prometheus
pushover
redmine
slack_slash_commands
slack
teamcity
microsoft_teams
......@@ -246,6 +253,14 @@ class Service < ActiveRecord::Base
service_names += %w[mock_ci mock_deployment mock_monitoring]
end
if show_gitlab_slack_application?
service_names.push('gitlab_slack_application')
end
unless Gitlab.com?
service_names.push('slack_slash_commands')
end
service_names.sort_by(&:downcase)
end
......@@ -256,6 +271,10 @@ class Service < ActiveRecord::Base
service
end
def self.show_gitlab_slack_application?
(Gitlab.com? && current_application_settings.slack_app_enabled) || Rails.env.development?
end
private
def cache_project_has_external_issue_tracker
......
class SlackIntegration < ActiveRecord::Base
belongs_to :service
validates :team_id, presence: true
validates :team_name, presence: true
validates :alias, presence: true,
uniqueness: { scope: :team_id, message: 'This alias has already been taken' }
validates :user_id, presence: true
validates :service, presence: true
after_commit :update_active_status_of_service, on: [:create, :destroy]
def update_active_status_of_service
service.update_active_status
end
end
module Projects
class SlackApplicationInstallService < BaseService
include Gitlab::Routing
SLACK_EXCHANGE_TOKEN_URL = 'https://slack.com/api/oauth.access'.freeze
def execute
slack_data = exchange_slack_token
return error("Slack: #{slack_data['error']}") unless slack_data['ok']
unless project.gitlab_slack_application_service
project.create_gitlab_slack_application_service
end
service = project.gitlab_slack_application_service
SlackIntegration.create!(
service_id: service.id,
team_id: slack_data['team_id'],
team_name: slack_data['team_name'],
alias: project.path_with_namespace,
user_id: slack_data['user_id']
)
make_sure_chat_name_created(slack_data)
success
end
private
def make_sure_chat_name_created(slack_data)
service = project.gitlab_slack_application_service
chat_name = ChatName.find_by(
service: service.id,
team_id: slack_data['team_id'],
chat_id: slack_data['user_id']
)
unless chat_name
ChatName.find_or_create_by!(
service_id: service.id,
team_id: slack_data['team_id'],
team_domain: slack_data['team_name'],
chat_id: slack_data['user_id'],
chat_name: slack_data['user_name'],
user: current_user
)
end
end
def exchange_slack_token
HTTParty.get(SLACK_EXCHANGE_TOKEN_URL, query: {
client_id: current_application_settings.slack_app_id,
client_secret: current_application_settings.slack_app_secret,
redirect_uri: slack_auth_project_settings_slack_url(project),
code: params[:code]
})
end
end
end
module SlashCommands
class GlobalSlackHandler
attr_reader :project_alias, :params
def initialize(params)
@project_alias, command = parse_command_text(params)
@params = params.merge(text: command, original_command: params[:text])
end
def trigger
return false unless valid_token?
if help_command?
return Gitlab::SlashCommands::ApplicationHelp.new(params).execute
end
unless integration = find_integration
error_message = 'GitLab error: project or alias not found'
return Gitlab::SlashCommands::Presenters::Error.new(error_message).message
end
service = integration.service
project = service.project
user = ChatNames::FindUserService.new(service, params).execute
if user
Gitlab::SlashCommands::Command.new(project, user, params).execute
else
url = ChatNames::AuthorizeUserService.new(service, params).execute
Gitlab::SlashCommands::Presenters::Access.new(url).authorize
end
end
private
def valid_token?
ActiveSupport::SecurityUtils.variable_size_secure_compare(
current_application_settings.slack_app_verification_token,
params[:token]
)
end
def help_command?
params[:original_command] == 'help'
end
def find_integration
SlackIntegration.find_by(team_id: params[:team_id], alias: project_alias)
end
# Splits the command
# '/gitlab help' => [nil, 'help']
# '/gitlab group/project issue new some title' => ['group/project', 'issue new some title']
def parse_command_text(params)
fragments = params[:text].split(/\s/, 2)
fragments.size == 1 ? [nil, fragments.first] : fragments
end
end
end
......@@ -686,6 +686,29 @@
if you have configured your OpenSSH server to use the
AuthorizedKeysCommand. Click on the help icon for more details.
= link_to icon('question-circle'), help_page_path('administration/operations/speed_up_ssh', anchor: 'the-solution')
- if Gitlab.com? || Rails.env.development?
%fieldset
%legend Slack application
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :slack_app_enabled do
= f.check_box :slack_app_enabled
Enable Slack application
.help-block
This option is only available on GitLab.com
.form-group
= f.label :slack_app_id, 'APP_ID', class: 'control-label col-sm-2'
.col-sm-10
= f.text_field :slack_app_id, class: 'form-control'
.form-group
= f.label :slack_app_secret, 'APP_SECRET', class: 'control-label col-sm-2'
.col-sm-10
= f.text_field :slack_app_secret, class: 'form-control'
.form-group
= f.label :slack_app_verification_token, 'Verification token', class: 'control-label col-sm-2'
.col-sm-10
= f.text_field :slack_app_verification_token, class: 'form-control'
- if Gitlab::Geo.license_allows?
%fieldset
......
......@@ -11,6 +11,7 @@
.col-lg-9
= form_for(@service, as: :service, url: project_service_path(@project, @service.to_param), method: :put, html: { class: 'gl-show-field-errors form-horizontal js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form|
= render 'shared/service_settings', form: form, subject: @service
- if @service.editable?
.footer-block.row-content-block
%button.btn.btn-save{ type: 'submit' }
= icon('spinner spin', class: 'hidden js-btn-spinner')
......
- pretty_name = defined?(@project) ? @project.name_with_namespace : 'namespace / path'
- run_actions_text = "Perform common operations on GitLab project: #{pretty_name}"
.well
%p
This service allows users to perform common operations on this
project by entering slash commands in Slack.
= link_to help_page_path('user/project/integrations/gitlab_slack_application.md'), target: '_blank' do
View documentation
= icon('external-link')
%p.inline
See the list of available commands in Slack after setting up this service
by entering
%kbd.inline /gitlab help
- unless @service.template?
%p To set up this service press "Add to Slack"
= render "projects/services/#{@service.to_param}/slack_integration_form"
%a{ href: "https://slack.com/oauth/authorize?scope=commands&client_id=#{slack_app_id}&redirect_uri=#{slack_redirect_uri(@project)}" }
%img{ alt:"Add to Slack", height: "40", src: "https://platform.slack-edge.com/img/add_to_slack.png", srcset: "https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x", width: "139" }
= render "projects/services/#{@service.to_param}/slack_button"
- slack_integration = @service.slack_integration
- if slack_integration
%table.table
%colgroup
%col
%col
%col.hidden-xs
%col{ width: "120" }
%thead
%tr
%th Team name
%th Project alias
%th Created at
%th Actions
%tr
%td
= slack_integration.team_name
%td
= slack_integration.alias
%td.light
= time_ago_in_words slack_integration.created_at
ago
%td.light
- project = @service.project
= link_to 'Remove', namespace_project_settings_slack_path(project.namespace, project), method: :delete, class: 'btn btn-danger', data: { confirm: 'Are you sure?' }
......@@ -7,6 +7,7 @@
= markdown @service.help
.service-settings
- if @service.show_active_box?
.form-group
= form.label :active, "Active", class: "control-label"
.col-sm-10
......
---
title: "[GitLab.com only] Add Slack applicationq service"
merge_request:
author:
......@@ -438,6 +438,11 @@ constraints(ProjectUrlConstrainer.new) do
resource :members, only: [:show]
resource :ci_cd, only: [:show], controller: 'ci_cd'
resource :integrations, only: [:show]
resource :slack, only: [:destroy] do
get :slack_auth
end
resource :repository, only: [:show], controller: :repository
end
......
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddSlackIntegrationtable < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index", "remove_concurrent_index" or
# "add_column_with_default" you must disable the use of transactions
# as these methods can not run in an existing transaction.
# When using "add_concurrent_index" or "remove_concurrent_index" methods make sure
# that either of them is the _only_ method called in the migration,
# any other changes should go in a separate migration.
# This ensures that upon failure _only_ the index creation or removing fails
# and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
# disable_ddl_transaction!
def change
create_table :slack_integrations do |t|
t.belongs_to :service, null: false, foreign_key: { on_delete: :cascade }, index: true
t.string :team_id, null: false
t.string :team_name, null: false
t.string :alias, null: false
t.string :user_id, null: false
t.index [:team_id, :alias], unique: true
t.timestamps_with_timezone
end
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddSlackToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index", "remove_concurrent_index" or
# "add_column_with_default" you must disable the use of transactions
# as these methods can not run in an existing transaction.
# When using "add_concurrent_index" or "remove_concurrent_index" methods make sure
# that either of them is the _only_ method called in the migration,
# any other changes should go in a separate migration.
# This ensures that upon failure _only_ the index creation or removing fails
# and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
# disable_ddl_transaction!
def change
add_column :application_settings, :slack_app_enabled, :boolean, default: false
add_column :application_settings, :slack_app_id, :string
add_column :application_settings, :slack_app_secret, :string
add_column :application_settings, :slack_app_verification_token, :string
end
end
......@@ -144,6 +144,10 @@ ActiveRecord::Schema.define(version: 20170627211700) do
t.boolean "authorized_keys_enabled", default: true, null: false
t.boolean "help_page_hide_commercial_content", default: false
t.string "help_page_support_url"
t.boolean "slack_app_enabled", default: false
t.string "slack_app_id"
t.string "slack_app_secret"
t.string "slack_app_verification_token"
end
create_table "approvals", force: :cascade do |t|
......@@ -1512,6 +1516,19 @@ ActiveRecord::Schema.define(version: 20170627211700) do
add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree
add_index "services", ["template"], name: "index_services_on_template", using: :btree
create_table "slack_integrations", force: :cascade do |t|
t.integer "service_id", null: false
t.string "team_id", null: false
t.string "team_name", null: false
t.string "alias", null: false
t.string "user_id", null: false
t.datetime "created_at"
t.datetime "updated_at"
end
add_index "slack_integrations", ["team_id", "alias"], name: "index_slack_integrations_on_team_id_and_alias", unique: true, using: :btree
add_index "slack_integrations", ["service_id"], name: "index_slack_integrations_on_service_id", using: :btree
create_table "snippets", force: :cascade do |t|
t.string "title"
t.text "content"
......@@ -1898,6 +1915,7 @@ ActiveRecord::Schema.define(version: 20170627211700) do
add_foreign_key "remote_mirrors", "projects", name: "fk_43a9aa4ca8", on_delete: :cascade
add_foreign_key "services", "projects", name: "fk_71cce407f9", on_delete: :cascade
add_foreign_key "snippets", "projects", name: "fk_be41fd4bb7", on_delete: :cascade
add_foreign_key "slack_integrations", "services", on_delete: :cascade
add_foreign_key "subscriptions", "projects", on_delete: :cascade
add_foreign_key "system_note_metadata", "notes", name: "fk_d83a918cb1", on_delete: :cascade
add_foreign_key "timelogs", "issues", name: "fk_timelogs_issues_issue_id", on_delete: :cascade
......
# Slack application (only available on GitLab.com)
Since GitLab 9.4 you can install GitLab.com Slack application to get [slash commands](https://docs.gitlab.com/ce/integration/slash_commands.html) working.
The only difference is that all the commands should be prefixed with `/gitlab` keyword:
```
# Show the issue #1001
/gitlab gitlab-org/gitlab-ce issue show 1001
```
To install GitLab application to your Slack team you need to go to
`Project Settings > Integration > Slack application` page and press "Add to Slack" button.
Keep in mind that you have to have appropriate permissions for that team to be able to
install a new application, see details in [Add an app to your team](https://get.slack.help/hc/en-us/articles/202035138-Adding-apps-to-your-team).
After confirming installation you, and everyone else in your Slack team, can use all the commands.
When you perform your first slash command you will be asked to authorize your Slack user
inside GitLab.com.
......@@ -770,5 +770,17 @@ module API
end
end
end
desc "Trigger a global slack command" do
detail 'Added in GitLab 9.4'
end
post 'slack/trigger' do
if result = SlashCommands::GlobalSlackHandler.new(params).trigger
status result[:status] || 200
present result
else
not_found!
end
end
end
end
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
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
require 'spec_helper'
describe Projects::Settings::SlacksController do
let(:project) { create(:empty_project, :public) }
let(:user) { create(:user) }
before do
project.team << [user, :master]
sign_in(user)
end
describe 'GET show' do
def redirect_url(project)
edit_project_service_path(
project,
project.build_gitlab_slack_application_service
)
end
def stub_service(result)
service = double
expect(service).to receive(:execute).and_return(result)
expect(Projects::SlackApplicationInstallService)
.to receive(:new).with(project, user, anything).and_return(service)
end
it 'calls service and redirects with no flash message if result is successful' do
stub_service(status: :success)
get :slack_auth, namespace_id: project.namespace, project_id: project
expect(response).to have_http_status(302)
expect(response).to redirect_to(redirect_url(project))
expect(flash[:alert]).to be_nil
end
it 'calls service and redirects with flash message if there is error' do
stub_service(status: :error, message: 'error')
get :slack_auth, namespace_id: project.namespace, project_id: project
expect(response).to have_http_status(302)
expect(response).to redirect_to(redirect_url(project))
expect(flash[:alert]).to eq('error')
end
end
end
......@@ -47,4 +47,9 @@ FactoryGirl.define do
type 'HipchatService'
token 'test_token'
end
factory :gitlab_slack_application_service do
project factory: :empty_project
type 'GitlabSlackApplicationService'
end
end
FactoryGirl.define do
factory :slack_integration do
sequence(:team_id) { |n| "T123#{n}" }
sequence(:user_id) { |n| "U123#{n}" }
sequence(:team_name) { |n| "team#{n}" }
sequence(:alias) { |n| "namespace#{n}/project_name#{n}" }
service factory: :gitlab_slack_application_service
end
end
......@@ -190,6 +190,7 @@ project:
- pipelines_email_service
- mattermost_slash_commands_service
- slack_slash_commands_service
- gitlab_slack_application_service
- irker_service
- pivotaltracker_service
- prometheus_service
......
require 'spec_helper'
describe Gitlab::SlashCommands::ApplicationHelp, service: true 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
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
require 'spec_helper'
describe SlackIntegration, models: true do
describe "Associations" do
it { is_expected.to belong_to(:service) }
end
describe 'Validations' do
it { is_expected.to validate_presence_of(:team_id) }
it { is_expected.to validate_presence_of(:team_name) }
it { is_expected.to validate_presence_of(:alias) }
it { is_expected.to validate_presence_of(:user_id) }
it { is_expected.to validate_presence_of(:service) }
end
end
......@@ -174,4 +174,21 @@ describe API::Services do
end
end
end
describe 'Slack application Service' do
before do
project.create_gitlab_slack_application_service
stub_application_setting(
slack_app_verification_token: 'token'
)
end
it 'returns status 200' do
post api('/slack/trigger'), token: 'token', text: 'help'
expect(response).to have_http_status(200)
expect(json_response['response_type']).to eq("ephemeral")
end
end
end
require 'spec_helper'
describe Projects::SlackApplicationInstallService, services: true do
let!(:user) { create(:user) }
let!(:project) { create(:project) }
def service(params = {})
Projects::SlackApplicationInstallService.new(project, user, params)
end
def stub_slack_response_with(response)
expect_any_instance_of(Projects::SlackApplicationInstallService)
.to receive(:exchange_slack_token).and_return(response.stringify_keys)
end
def expect_slack_integration_is_created(project)
integration = SlackIntegration.find_by(service_id: project.gitlab_slack_application_service.id)
expect(integration).to be_present
end
def expect_chat_name_is_created(project)
chat_name = ChatName.find_by(service_id: project.gitlab_slack_application_service.id)
expect(chat_name).to be_present
end
it 'returns error result' do
stub_slack_response_with(ok: false, error: 'something is wrong')
result = service.execute
expect(result).to eq(message: 'Slack: something is wrong', status: :error)
end
it 'returns success result and creates all the needed records' do
stub_slack_response_with(
ok: true,
access_token: 'XXXX',
user_id: 'U12345',
team_id: 'T1265',
team_name: 'super-team'
)
result = service.execute
expect(result).to eq(status: :success)
expect_slack_integration_is_created(project)
expect_chat_name_is_created(project)
end
end
require 'spec_helper'
describe SlashCommands::GlobalSlackHandler, service: true do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:verification_token) { '123' }
before do
stub_application_setting(
slack_app_verification_token: verification_token
)
end
def enable_slack_application(project)
create(:gitlab_slack_application_service, project: project)
end
def handler(params)
SlashCommands::GlobalSlackHandler.new(params)
end
def handler_with_valid_token(params)
handler(params.merge(token: verification_token))
end
it 'does not serve a request if token is invalid' do
result = handler(token: '123456', text: 'help').trigger
expect(result).to be_falsey
end
context 'Valid token' do
it 'calls command handler if project alias is valid' do
expect_any_instance_of(Gitlab::SlashCommands::Command).to receive(:execute)
expect_any_instance_of(ChatNames::FindUserService).to receive(:execute).and_return(user)
enable_slack_application(project)
slack_integration = create(:slack_integration, service: project.gitlab_slack_application_service)
slack_integration.update(alias: project.path_with_namespace)
handler_with_valid_token(
text: "#{project.path_with_namespace} issue new title",
team_id: slack_integration.team_id
).trigger
end
it 'returns error if project alias not found' do
expect_any_instance_of(Gitlab::SlashCommands::Command).not_to receive(:execute)
expect_any_instance_of(Gitlab::SlashCommands::Presenters::Error).to receive(:message)
enable_slack_application(project)
slack_integration = create(:slack_integration, service: project.gitlab_slack_application_service)
handler_with_valid_token(
text: "fake/fake issue new title",
team_id: slack_integration.team_id
).trigger
end
it 'returns authorization request' do
expect_any_instance_of(ChatNames::AuthorizeUserService).to receive(:execute)
expect_any_instance_of(Gitlab::SlashCommands::Presenters::Access).to receive(:authorize)
enable_slack_application(project)
slack_integration = create(:slack_integration, service: project.gitlab_slack_application_service)
slack_integration.update(alias: project.path_with_namespace)
handler_with_valid_token(
text: "#{project.path_with_namespace} issue new title",
team_id: slack_integration.team_id
).trigger
end
it 'calls help presenter' do
expect_any_instance_of(Gitlab::SlashCommands::ApplicationHelp).to receive(:execute)
handler_with_valid_token(
text: "help"
).trigger
end
end
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