From 38e087e94380e095401be477c874a67a5e328a55 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Thu, 8 Sep 2016 12:42:02 +0200 Subject: [PATCH 1/5] Add route for integration triggers --- app/controllers/integrations_controller.rb | 7 +++++++ config/routes.rb | 5 +++++ spec/controllers/integrations_controller_spec.rb | 5 +++++ 3 files changed, 17 insertions(+) create mode 100644 app/controllers/integrations_controller.rb create mode 100644 spec/controllers/integrations_controller_spec.rb diff --git a/app/controllers/integrations_controller.rb b/app/controllers/integrations_controller.rb new file mode 100644 index 000000000000..338abf2938ab --- /dev/null +++ b/app/controllers/integrations_controller.rb @@ -0,0 +1,7 @@ +class IntegrationsController < ApplicationController + respond_to :json + + def trigger + + end +end diff --git a/config/routes.rb b/config/routes.rb index 4d6ec699cbde..ebab95a8a461 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -137,6 +137,11 @@ # resources :notification_settings, only: [:create, :update] + # + # Integrations (trigger actions because of payload) + # + resources :integrations, only: [:trigger] + # # Import # diff --git a/spec/controllers/integrations_controller_spec.rb b/spec/controllers/integrations_controller_spec.rb new file mode 100644 index 000000000000..2faaf77d1b74 --- /dev/null +++ b/spec/controllers/integrations_controller_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe IntegrationsController, type: :controller do + +end -- GitLab From 458fa96d25aacb5b616e9882c9fd479c4bcaa3b4 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Tue, 13 Sep 2016 10:04:17 +0200 Subject: [PATCH 2/5] Endpoint to accept triggers, mock response --- app/controllers/integrations_controller.rb | 18 ++++++++++++++++++ config/routes.rb | 6 ++++-- .../integrations_controller_spec.rb | 7 +++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/controllers/integrations_controller.rb b/app/controllers/integrations_controller.rb index 338abf2938ab..8852a048100e 100644 --- a/app/controllers/integrations_controller.rb +++ b/app/controllers/integrations_controller.rb @@ -1,7 +1,25 @@ class IntegrationsController < ApplicationController respond_to :json + skip_before_filter :verify_authenticity_token + skip_before_action :authenticate_user! + + def trigger + render json: slack_response(Issue.last).to_json + end + + private + def slack_response(resource) + { + response_type: "in_channel", + text: "#{resource.title}", + attachments: [ + { + "text":"#{resource.description}" + } + ] + } end end diff --git a/config/routes.rb b/config/routes.rb index ebab95a8a461..aaf7a6d8ee15 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -138,9 +138,11 @@ resources :notification_settings, only: [:create, :update] # - # Integrations (trigger actions because of payload) + # Integrations # - resources :integrations, only: [:trigger] + namespace :integrations do + post :trigger + end # # Import diff --git a/spec/controllers/integrations_controller_spec.rb b/spec/controllers/integrations_controller_spec.rb index 2faaf77d1b74..f1c2360ca6f4 100644 --- a/spec/controllers/integrations_controller_spec.rb +++ b/spec/controllers/integrations_controller_spec.rb @@ -2,4 +2,11 @@ RSpec.describe IntegrationsController, type: :controller do + describe 'POST trigger' do + it 'returns a 200 status code' do + post :trigger, format: :json + + expect(response).to have_http_status(200) + end + end end -- GitLab From 7cb7787a8b7a84ed14484252ecc379b70ba27086 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Tue, 13 Sep 2016 16:08:27 +0200 Subject: [PATCH 3/5] Implement services to parse and reply Also a way to put the tokens in. At this time, the integration works for both Slack and Mattermost. Mattermost is way easier to test at this point as you can run it on your own machine. --- app/controllers/integrations_controller.rb | 41 ++++++-- .../projects/integrations_controller.rb | 60 +++++++++++ app/models/integration.rb | 3 + app/models/project.rb | 2 + app/policies/integration_policy.rb | 5 + app/policies/project_policy.rb | 4 + app/services/integrations/base_service.rb | 99 +++++++++++++++++++ app/services/integrations/issue_service.rb | 24 +++++ .../integrations/merge_request_service.rb | 24 +++++ app/services/integrations/pipeline_service.rb | 46 +++++++++ .../integrations/project_snippet_service.rb | 30 ++++++ .../layouts/nav/_project_settings.html.haml | 5 + .../projects/integrations/_form.html.haml | 22 +++++ .../integrations/_header_title.html.haml | 1 + .../projects/integrations/edit.html.haml | 6 ++ .../projects/integrations/index.html.haml | 25 +++++ app/views/projects/integrations/new.html.haml | 6 ++ .../projects/integrations/show.html.haml | 12 +++ config/routes.rb | 2 + .../20160913092152_create_integrations.rb | 13 +++ db/schema.rb | 12 +++ .../integrations_controller_spec.rb | 47 ++++++++- spec/factories/integrations.rb | 8 ++ spec/models/integration_spec.rb | 5 + .../integrations/issue_service_spec.rb | 45 +++++++++ .../merge_request_service_spec.rb | 31 ++++++ .../integrations/pipeline_service_spec.rb | 19 ++++ .../project_snippet_service_spec.rb | 19 ++++ 28 files changed, 603 insertions(+), 13 deletions(-) create mode 100644 app/controllers/projects/integrations_controller.rb create mode 100644 app/models/integration.rb create mode 100644 app/policies/integration_policy.rb create mode 100644 app/services/integrations/base_service.rb create mode 100644 app/services/integrations/issue_service.rb create mode 100644 app/services/integrations/merge_request_service.rb create mode 100644 app/services/integrations/pipeline_service.rb create mode 100644 app/services/integrations/project_snippet_service.rb create mode 100644 app/views/projects/integrations/_form.html.haml create mode 100644 app/views/projects/integrations/_header_title.html.haml create mode 100644 app/views/projects/integrations/edit.html.haml create mode 100644 app/views/projects/integrations/index.html.haml create mode 100644 app/views/projects/integrations/new.html.haml create mode 100644 app/views/projects/integrations/show.html.haml create mode 100644 db/migrate/20160913092152_create_integrations.rb create mode 100644 spec/factories/integrations.rb create mode 100644 spec/models/integration_spec.rb create mode 100644 spec/services/integrations/issue_service_spec.rb create mode 100644 spec/services/integrations/merge_request_service_spec.rb create mode 100644 spec/services/integrations/pipeline_service_spec.rb create mode 100644 spec/services/integrations/project_snippet_service_spec.rb diff --git a/app/controllers/integrations_controller.rb b/app/controllers/integrations_controller.rb index 8852a048100e..a48015c8b981 100644 --- a/app/controllers/integrations_controller.rb +++ b/app/controllers/integrations_controller.rb @@ -4,22 +4,45 @@ class IntegrationsController < ApplicationController skip_before_filter :verify_authenticity_token skip_before_action :authenticate_user! + before_action :integration def trigger - render json: slack_response(Issue.last).to_json + service = service(params[:command]) + + if integration && service + render json: service.new(project, nil, params).execute + else + render json: no_integration_found + end end private - def slack_response(resource) + def no_integration_found { - response_type: "in_channel", - text: "#{resource.title}", - attachments: [ - { - "text":"#{resource.description}" - } - ] + response_type: :ephemeral, + text: 'This slash command has not been registered yet.', } end + + def integration + @integration ||= Integration.find_by(external_token: params[:token]) + end + + def project + integration.project + end + + def service(command) + case command + when '/issue' + Integrations::IssueService + when '/merge_request' + Integrations::MergeRequestService + when '/environment' + Integrations::EnvironmentService + when '/snippet' + Integrations::ProjectSnippetService + end + end end diff --git a/app/controllers/projects/integrations_controller.rb b/app/controllers/projects/integrations_controller.rb new file mode 100644 index 000000000000..a14d508e18b1 --- /dev/null +++ b/app/controllers/projects/integrations_controller.rb @@ -0,0 +1,60 @@ +class Projects::IntegrationsController < Projects::ApplicationController + layout 'project' + + before_action :authorize_read_integration! + before_action :authorize_create_integration!, only: [:new, :create] + before_action :authorize_update_integration!, only: [:edit, :update, :destroy] + before_action :integration, only: [:show, :edit, :update, :destroy] + + def index + @integrations = project.integrations + end + + def show + end + + def new + @integration = project.integrations.new + end + + def edit + end + + def create + @integration = project.integrations.create(integration_params) + + if @integration.persisted? + redirect_to namespace_project_integration_path(project.namespace, project, @integration) + else + render :new + end + end + + def update + if @integration.update(integration_params) + redirect_to namespace_project_integration_path(project.namespace, project, @integration) + else + render :edit + end + end + + def destroy + if @integration.destroy + flash[:notice] = 'Integration was successfully removed.' + else + flash[:alert] = 'Failed to remove integration.' + end + + redirect_to namespace_project_integration_path(project.namespace, project) + end + + private + + def integration_params + params.require(:integration).permit(:name, :external_token) + end + + def integration + @integration ||= project.integrations.find(params[:id]) + end +end diff --git a/app/models/integration.rb b/app/models/integration.rb new file mode 100644 index 000000000000..8e73e8224324 --- /dev/null +++ b/app/models/integration.rb @@ -0,0 +1,3 @@ +class Integration < ActiveRecord::Base + belongs_to :project +end diff --git a/app/models/project.rb b/app/models/project.rb index 7265cb55594a..990a4ff124ab 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -140,6 +140,8 @@ def update_forks_visibility_level has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :gl_project_id has_many :environments, dependent: :destroy has_many :deployments, dependent: :destroy + has_many :integrations, dependent: :destroy + accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature diff --git a/app/policies/integration_policy.rb b/app/policies/integration_policy.rb new file mode 100644 index 000000000000..daec3ddb18cc --- /dev/null +++ b/app/policies/integration_policy.rb @@ -0,0 +1,5 @@ +class IntegrationPolicy < BasePolicy + def rules + delegate! @subject.project + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index be25c750d674..51dfbfb30d2f 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -88,6 +88,7 @@ def developer_access! can! :update_container_image can! :create_environment can! :create_deployment + can! :read_integration end def master_access! @@ -95,6 +96,9 @@ def master_access! can! :update_project_snippet can! :update_environment can! :update_deployment + can! :create_integration + can! :update_integration + can! :destroy_integration can! :admin_milestone can! :admin_project_snippet can! :admin_project_member diff --git a/app/services/integrations/base_service.rb b/app/services/integrations/base_service.rb new file mode 100644 index 000000000000..2e72c12b5f5e --- /dev/null +++ b/app/services/integrations/base_service.rb @@ -0,0 +1,99 @@ +module Integrations + class BaseService < ::BaseService + def execute + resource = + if resource_id + find_resource + else + collection.search(params[:text]).limit(10) + end + + generate_response(resource) + end + + private + + def resource_id + if params[:text].is_a?(Integer) || params[:text].match(/\A\d+\z/) + params[:text].to_i + else + nil + end + end + + def klass + raise NotImplementedError + end + + def find_resource + raise NotImplementedError + end + + def title + raise NotImplementedError + end + + def link(*args) + raise NotImplementedError + end + + # Used when returning a collection + def to_attachment(resource) + { + "title": "Title", + "text": "Testing *right now!*", + "mrkdwn_in": [ + "text", + "pretext" + ] + } + end + + def collection + klass.where(project: project) + end + + def generate_response(resource) + if resource.nil? + respond_404 + elsif resource.respond_to?(:count) + return generate_response(resource.first) if resource.count == 1 + return no_search_results if resource.empty? + + { + text: "Search results for #{params[:text]}", + response_type: :ephemeral, + attachments: resource.map { |item| to_attachment(item) } + } + else + { + text: slack_format(title(resource)), + response_type: :in_channel, + mrkdwn_in: [ + :text, + :pretext + ] + } + end + end + + def slack_format(message) + Slack::Notifier::LinkFormatter.format(message) + end + + def no_search_results + { + text: "No search results for #{params[:text]}. :(", + response_type: :ephemeral, + attachments: [] + } + end + + def respond_404 + { + text: "404 not found! Please make you use the right identifier. :boom:", + response_type: :ephemeral + } + end + end +end diff --git a/app/services/integrations/issue_service.rb b/app/services/integrations/issue_service.rb new file mode 100644 index 000000000000..6d6453b7e4c0 --- /dev/null +++ b/app/services/integrations/issue_service.rb @@ -0,0 +1,24 @@ +module Integrations + class IssueService < BaseService + + private + + def klass + Issue + end + + def title(issue) + "[##{issue.iid} #{issue.title}](#{link(issue)})" + end + + def link(issue) + Gitlab::Routing.url_helpers.namespace_project_issue_url(project.namespace, + project, + issue) + end + + def find_resource + collection.find_by(iid: params[:text]) + end + end +end diff --git a/app/services/integrations/merge_request_service.rb b/app/services/integrations/merge_request_service.rb new file mode 100644 index 000000000000..d5a958218247 --- /dev/null +++ b/app/services/integrations/merge_request_service.rb @@ -0,0 +1,24 @@ +module Integrations + class MergeRequestService < BaseService + + private + + def klass + MergeRequest + end + + def title(merge_request) + "[!#{merge_request.iid} #{merge_request.title}](#{link(merge_request)})" + end + + def link(merge_request) + Gitlab::Routing.url_helpers.namespace_project_merge_request_url(project.namespace, + project, + merge_request) + end + + def find_resource + collection.find_by(iid: params[:text]) + end + end +end diff --git a/app/services/integrations/pipeline_service.rb b/app/services/integrations/pipeline_service.rb new file mode 100644 index 000000000000..738bd19f5817 --- /dev/null +++ b/app/services/integrations/pipeline_service.rb @@ -0,0 +1,46 @@ +module Integrations + class PipelineService < BaseService + + def execute + resource = + if resource_id + merge_request_pipeline(resource_id) + else + pipeline_by_ref(params[:text]) + end + + generate_response(resource) + end + + private + + def merge_request_pipeline(iid) + project.merge_requests.find_by(iid: iid).pipeline + end + + def pipeline_by_ref(ref) + project.pipelines.where(ref: ref).last + end + + def klass + Pipeline + end + + def title(pipeline) + "[##{pipeline.id} Pipeline for #{pipeline.ref}: #{pipeline.status}](#{link(pipeline)})" + end + + def link(pipeline) + Gitlab::Routing.url_helpers.namespace_project_pipeline_url(project.namespace, + project, + pipeline) + end + + def attachment(pipeline) + { + text: "Status: #{pipeline.status}", + color: "#C95823" + } + end + end +end diff --git a/app/services/integrations/project_snippet_service.rb b/app/services/integrations/project_snippet_service.rb new file mode 100644 index 000000000000..20eca22d9fda --- /dev/null +++ b/app/services/integrations/project_snippet_service.rb @@ -0,0 +1,30 @@ +module Integrations + class ProjectSnippetService < BaseService + + private + + def klass + ProjectSnippet + end + + def find_resource + collection.find_by(id: params[:text]) + end + + def title(snippet) + "[$#{snippet.id} #{snippet.title}](#{link(snippet)})" + end + + def link(snippet) + Gitlab::Routing.url_helpers.namespace_project_snippet_url(project.namespace, + project, snippet) + end + + def attachment(snippet) + { + text: slack_format(snippet.content), + color: '#C95823', + } + end + end +end diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index 613b8b7d3013..c52c5d991989 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -21,6 +21,11 @@ = link_to namespace_project_services_path(@project.namespace, @project), title: 'Services' do %span Services + = nav_link(controller: :integrations) do + = link_to namespace_project_integrations_path(@project.namespace, @project), title: 'Integrations' do + %span + Integrations + = nav_link(controller: :protected_branches) do = link_to namespace_project_protected_branches_path(@project.namespace, @project), title: 'Protected Branches' do %span diff --git a/app/views/projects/integrations/_form.html.haml b/app/views/projects/integrations/_form.html.haml new file mode 100644 index 000000000000..ddab61210113 --- /dev/null +++ b/app/views/projects/integrations/_form.html.haml @@ -0,0 +1,22 @@ +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + Integrations + %p + Integrations allow services to communicate with GitLab to enrich the experience. + = succeed "." do + = link_to "Read more about integrations", help_page_path("integrations") + + = form_for [@project.namespace.becomes(Namespace), @project, @integration], html: { class: 'col-lg-9' } do |f| + = form_errors(@integration) + + .form-group + = f.label :name, 'Name', class: 'label-light' + = f.text_field :name, required: true, class: 'form-control' + .form-group + = f.label :external_token, 'External token', class: 'label-light' + = f.text_field :external_token, class: 'form-control' + + .form-actions + = f.submit 'Save', class: 'btn btn-save' + = link_to 'Cancel', namespace_project_integrations_path(@project.namespace, @project), class: 'btn btn-cancel' diff --git a/app/views/projects/integrations/_header_title.html.haml b/app/views/projects/integrations/_header_title.html.haml new file mode 100644 index 000000000000..9c55ec803c02 --- /dev/null +++ b/app/views/projects/integrations/_header_title.html.haml @@ -0,0 +1 @@ +- header_title project_title(@project, "Integrations", project_integrations_path(@project)) diff --git a/app/views/projects/integrations/edit.html.haml b/app/views/projects/integrations/edit.html.haml new file mode 100644 index 000000000000..cbb3c7ad87a8 --- /dev/null +++ b/app/views/projects/integrations/edit.html.haml @@ -0,0 +1,6 @@ +- page_title "Edit", @integration.name, "Integrations" + +%h3.page-title + Edit integration +%hr += render 'form' diff --git a/app/views/projects/integrations/index.html.haml b/app/views/projects/integrations/index.html.haml new file mode 100644 index 000000000000..6d116979368b --- /dev/null +++ b/app/views/projects/integrations/index.html.haml @@ -0,0 +1,25 @@ +- page_title "Integrations" +%h3.page-title Integrations +%p.light Integrations allow other services to request data on this project. + +%div + - if can?(current_user, :create_integration, @project) + .top-area.nav-controls + = link_to new_namespace_project_integration_path(@project.namespace, @project), class: 'btn btn-create' do + New integration + + - unless @integrations.blank? + .table-holder + %table.table + %thead + %tr + %th Name + %th Enabled since + - @integrations.sort_by(&:type).each do |integration| + %tr + %td + = link_to edit_namespace_project_integration_path(@project.namespace, @project, integration.id) do + %strong= integration.name + %td.light + = time_ago_in_words integration.created_at + ago diff --git a/app/views/projects/integrations/new.html.haml b/app/views/projects/integrations/new.html.haml new file mode 100644 index 000000000000..ff1dfe4d734b --- /dev/null +++ b/app/views/projects/integrations/new.html.haml @@ -0,0 +1,6 @@ +- page_title 'New Integration' + +%h3.page-title + New Integration +%hr += render 'form' diff --git a/app/views/projects/integrations/show.html.haml b/app/views/projects/integrations/show.html.haml new file mode 100644 index 000000000000..c5f2737b438a --- /dev/null +++ b/app/views/projects/integrations/show.html.haml @@ -0,0 +1,12 @@ +- @no_container = true +- page_title "Integrations" + +%div{ class: container_class } + .top-area + .col-md-9 + %h3.page-title= @integration.name.capitalize + .col-md-3 + .nav-controls + - if can?(current_user, :update_integration, @integration) + = link_to 'Edit', edit_namespace_project_integration_path(@project.namespace, @project, @integration), class: 'btn' + = link_to 'Destroy', namespace_project_integration_path(@project.namespace, @project, @integration), data: { confirm: 'Are you sure you want to delete this integration?' }, class: 'btn btn-danger', method: :delete diff --git a/config/routes.rb b/config/routes.rb index aaf7a6d8ee15..bf07730acb1c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -707,6 +707,8 @@ end end + resources :integrations + resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create] do member do put :enable diff --git a/db/migrate/20160913092152_create_integrations.rb b/db/migrate/20160913092152_create_integrations.rb new file mode 100644 index 000000000000..04ece0be8b2f --- /dev/null +++ b/db/migrate/20160913092152_create_integrations.rb @@ -0,0 +1,13 @@ +class CreateIntegrations < ActiveRecord::Migration + def change + create_table :integrations do |t| + t.references :project, index: true, foreign_key: true + t.string :name + t.string :external_token + + t.timestamps null: false + end + + add_index :integrations, :external_token, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 59b3e2377077..a3c481f4dc06 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -439,6 +439,17 @@ add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree + create_table "integrations", force: :cascade do |t| + t.integer "project_id" + t.string "name" + t.string "external_token" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "integrations", ["external_token"], name: "index_integrations_on_external_token", unique: true, using: :btree + add_index "integrations", ["project_id"], name: "index_integrations_on_project_id", using: :btree + create_table "issue_metrics", force: :cascade do |t| t.integer "issue_id", null: false t.datetime "first_mentioned_in_commit_at" @@ -1181,6 +1192,7 @@ add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree add_foreign_key "boards", "projects" + add_foreign_key "integrations", "projects" add_foreign_key "issue_metrics", "issues", on_delete: :cascade add_foreign_key "lists", "boards" add_foreign_key "lists", "labels" diff --git a/spec/controllers/integrations_controller_spec.rb b/spec/controllers/integrations_controller_spec.rb index f1c2360ca6f4..00c1f03690bf 100644 --- a/spec/controllers/integrations_controller_spec.rb +++ b/spec/controllers/integrations_controller_spec.rb @@ -1,12 +1,51 @@ require 'rails_helper' RSpec.describe IntegrationsController, type: :controller do - describe 'POST trigger' do - it 'returns a 200 status code' do - post :trigger, format: :json + context 'when Slack triggers a request' do + let(:slack_params) do + { + format: :json, + "token"=>"24randomcharacters", + "team_id"=>"T123456T9", + "team_domain"=>"mepmep", + "channel_id"=>"C12345678", + "channel_name"=>"general", + "user_id"=>"U12345678", + "user_name"=>"mep", + "command"=>"/issue", + "text"=>"3", + "response_url"=>"https://hooks.slack.com/commands/T123456T9/79958163905/siWqY7Qtx8z0zWFsXBod9VEy" + } + end + + let(:json_response) { JSON.parse(response.body) } + + it 'returns a 200 status code' do + post :trigger, slack_params + + expect(response).to have_http_status(200) + expect(json_response['response_type']).to eq 'ephemeral' + expect(json_response['text']).to eq 'This slash command has not been registered yet.' + end + + context 'when the integration is registered' do + let!(:slack_integration) { create(:integration) } + let(:issue) { create(:issue, project: slack_integration.project) } + + describe 'lookup issue on ID' do + it 'returns the wanted resource' do + slack_params["token"] = slack_integration.external_token + slack_params['text'] = issue.iid + + post :trigger, slack_params - expect(response).to have_http_status(200) + expect(response).to have_http_status(200) + expect(json_response['response_type']).to eq 'in_channel' + expect(json_response['text']).to match /#\d+\s#{Regexp.quote(issue.title)}/ + end + end + end end end end diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb new file mode 100644 index 000000000000..49a8c1c59e87 --- /dev/null +++ b/spec/factories/integrations.rb @@ -0,0 +1,8 @@ +FactoryGirl.define do + factory :integration, class: Integration do + sequence(:name) { |n| "integration#{n}" } + + project factory: :empty_project + external_token { ('a'..'z').to_a.shuffle.join } + end +end diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb new file mode 100644 index 000000000000..aae8d31dae1f --- /dev/null +++ b/spec/models/integration_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Integration, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/services/integrations/issue_service_spec.rb b/spec/services/integrations/issue_service_spec.rb new file mode 100644 index 000000000000..bf28aacc71da --- /dev/null +++ b/spec/services/integrations/issue_service_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe Integrations::IssueService, services: true do + let(:project) { create(:empty_project) } + let(:service) { described_class.new(project, nil, params) } + + subject { service.execute } + + describe '#execute' do + context 'looking up by IID' do + let(:issue) { create(:issue, project: project) } + let(:params) { { text: issue.iid } } + + it 'returns the issue by IID' do + expect(subject[:text]).to match /#\d+\s#{Regexp.quote(issue.title)}/ + end + + context 'the IID is passed as string' do + let(:params) { { text: issue.iid.to_s } } + + it 'returns the issue by IID' do + expect(subject[:text]).to match /#\d+\s#{Regexp.quote(issue.title)}/ + end + end + end + + context 'when looking for a non existing IID' do + let(:params) { { text: 123456 } } + it "returns not found when the IID does not exist" do + expect(subject[:text]).to match /404\ not\ found!/ + end + end + + context 'when searching on query' do + let(:params) { { text: 'Mepmep' } } + + it 'returns the search results' do + create(:issue, project: project, title: 'Mepmep this title') + create(:issue, project: project, title: 'this title Mepmep') + + expect(subject[:text]).to start_with "Search results for " + end + end + end +end diff --git a/spec/services/integrations/merge_request_service_spec.rb b/spec/services/integrations/merge_request_service_spec.rb new file mode 100644 index 000000000000..9e4e5f21951f --- /dev/null +++ b/spec/services/integrations/merge_request_service_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe Integrations::MergeRequestService, services: true do + let(:project) { create(:empty_project) } + let(:service) { described_class.new(project, nil, params) } + + subject { service.execute } + + describe '#execute' do + context 'looking up by IID' do + let(:params) { { text: mr.iid } } + let(:mr) { create(:merge_request, source_project: project) } + + it 'returns the issue by ID' do + expect(subject[:text]).to match /!\d+\s#{Regexp.quote(mr.title)}/ + end + end + + context 'when searching with only one result' do + let(:title) { 'Aint this a String?' } + let(:params) { { text: title[2..7] } } + + it 'returns the search results' do + create(:merge_request, source_project: project, title: title) + create(:merge_request, source_project: project) + + expect(subject[:text]).to match /!\d+\sAint\sthis/ + end + end + end +end diff --git a/spec/services/integrations/pipeline_service_spec.rb b/spec/services/integrations/pipeline_service_spec.rb new file mode 100644 index 000000000000..1e4a2561f8c2 --- /dev/null +++ b/spec/services/integrations/pipeline_service_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe Integrations::PipelineService, services: true do + let(:project) { create(:empty_project) } + let(:service) { described_class.new(project, nil, params) } + let(:pipeline) { create(:ci_pipeline_without_jobs, project: project) } + + subject { service.execute } + + describe '#execute' do + context 'lookup by ref' do + let(:params) { { text: pipeline.ref } } + + it 'returns the pipeline by ID' do + expect(subject[:text]).to match /#\d+\ Pipeline\ for\ #{pipeline.ref}: #{pipeline.status}/ + end + end + end +end diff --git a/spec/services/integrations/project_snippet_service_spec.rb b/spec/services/integrations/project_snippet_service_spec.rb new file mode 100644 index 000000000000..8d2a16ac815a --- /dev/null +++ b/spec/services/integrations/project_snippet_service_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe Integrations::ProjectSnippetService, services: true do + let(:project) { create(:empty_project) } + let(:service) { described_class.new(project, nil, params) } + + subject { service.execute } + + describe '#execute' do + context 'looking up by ID' do + let(:snippet) { create(:project_snippet, project: project) } + let(:params) { { text: snippet.id } } + + it 'returns the issue by ID' do + expect(subject[:text]).to match /\$\d+\s#{Regexp.quote(snippet.title)}/ + end + end + end +end -- GitLab From 6de9859263434d3a27f0a9c91ff5285c94816904 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Tue, 20 Sep 2016 19:01:33 +0300 Subject: [PATCH 4/5] Format messages for Slack --- app/controllers/integrations_controller.rb | 6 +- app/services/integrations/base_service.rb | 79 ++++++++++++------- app/services/integrations/issue_service.rb | 6 +- .../integrations/merge_request_service.rb | 10 +-- app/services/integrations/pipeline_service.rb | 8 +- .../integrations/project_snippet_service.rb | 22 +++++- .../projects/integrations/_form.html.haml | 16 ++-- .../projects/integrations/index.html.haml | 9 ++- app/views/projects/integrations/new.html.haml | 4 +- .../integrations_controller_spec.rb | 2 +- .../integrations/issue_service_spec.rb | 5 +- 11 files changed, 109 insertions(+), 58 deletions(-) diff --git a/app/controllers/integrations_controller.rb b/app/controllers/integrations_controller.rb index a48015c8b981..bb2705f1ff97 100644 --- a/app/controllers/integrations_controller.rb +++ b/app/controllers/integrations_controller.rb @@ -37,10 +37,10 @@ def service(command) case command when '/issue' Integrations::IssueService - when '/merge_request' + when '/merge-request' Integrations::MergeRequestService - when '/environment' - Integrations::EnvironmentService + when '/pipeline' + Integrations::PipelineService when '/snippet' Integrations::ProjectSnippetService end diff --git a/app/services/integrations/base_service.rb b/app/services/integrations/base_service.rb index 2e72c12b5f5e..4f91dc7ec1cf 100644 --- a/app/services/integrations/base_service.rb +++ b/app/services/integrations/base_service.rb @@ -13,23 +13,15 @@ def execute private - def resource_id - if params[:text].is_a?(Integer) || params[:text].match(/\A\d+\z/) - params[:text].to_i - else - nil - end - end - def klass raise NotImplementedError end def find_resource - raise NotImplementedError + collection.find_by(iid: resource_id) end - def title + def title(*args) raise NotImplementedError end @@ -37,16 +29,16 @@ def link(*args) raise NotImplementedError end - # Used when returning a collection - def to_attachment(resource) - { - "title": "Title", - "text": "Testing *right now!*", - "mrkdwn_in": [ - "text", - "pretext" - ] - } + def resource_id + if params[:text].is_a?(Integer) || params[:text].match(/\A\d+\z/) + params[:text].to_i + else + nil + end + end + + def fallback(*args) + raise NotImplementedError end def collection @@ -61,18 +53,14 @@ def generate_response(resource) return no_search_results if resource.empty? { - text: "Search results for #{params[:text]}", response_type: :ephemeral, - attachments: resource.map { |item| to_attachment(item) } + text: "Search results for #{params[:text]}", + attachments: resource.map { |item| small_attachment(item) } } else { - text: slack_format(title(resource)), response_type: :in_channel, - mrkdwn_in: [ - :text, - :pretext - ] + attachments: [ large_attachment(resource) ] } end end @@ -84,8 +72,7 @@ def slack_format(message) def no_search_results { text: "No search results for #{params[:text]}. :(", - response_type: :ephemeral, - attachments: [] + response_type: :ephemeral } end @@ -95,5 +82,39 @@ def respond_404 response_type: :ephemeral } end + + def large_attachment(issuable) + small_attachment(issuable).merge(fields: fields(issuable)) + end + + def small_attachment(issuable) + { + fallback: issuable.title, + title: title(issuable), + title_link: link(issuable), + text: issuable.description || "", # Slack doesn't like null + color: "#C95823" + } + end + + def fields(issuable) + result = [ + { + title: 'Author', + value: issuable.author.name, + short: true + } + ] + + if issuable.assignee + result << { + title: 'Assignee', + value: issuable.assignee.name, + short: true + } + end + + result + end end end diff --git a/app/services/integrations/issue_service.rb b/app/services/integrations/issue_service.rb index 6d6453b7e4c0..b00901afe3d9 100644 --- a/app/services/integrations/issue_service.rb +++ b/app/services/integrations/issue_service.rb @@ -8,7 +8,7 @@ def klass end def title(issue) - "[##{issue.iid} #{issue.title}](#{link(issue)})" + format("##{issue.iid} #{issue.title}") end def link(issue) @@ -16,9 +16,5 @@ def link(issue) project, issue) end - - def find_resource - collection.find_by(iid: params[:text]) - end end end diff --git a/app/services/integrations/merge_request_service.rb b/app/services/integrations/merge_request_service.rb index d5a958218247..6d8b3485cb59 100644 --- a/app/services/integrations/merge_request_service.rb +++ b/app/services/integrations/merge_request_service.rb @@ -7,8 +7,12 @@ def klass MergeRequest end + def collection + klass.where(target_project: project) + end + def title(merge_request) - "[!#{merge_request.iid} #{merge_request.title}](#{link(merge_request)})" + format("!#{merge_request.iid} #{merge_request.title}") end def link(merge_request) @@ -16,9 +20,5 @@ def link(merge_request) project, merge_request) end - - def find_resource - collection.find_by(iid: params[:text]) - end end end diff --git a/app/services/integrations/pipeline_service.rb b/app/services/integrations/pipeline_service.rb index 738bd19f5817..d4562fe6ecd8 100644 --- a/app/services/integrations/pipeline_service.rb +++ b/app/services/integrations/pipeline_service.rb @@ -27,7 +27,7 @@ def klass end def title(pipeline) - "[##{pipeline.id} Pipeline for #{pipeline.ref}: #{pipeline.status}](#{link(pipeline)})" + "##{pipeline.id} Pipeline for #{pipeline.ref}: #{pipeline.status}" end def link(pipeline) @@ -36,9 +36,11 @@ def link(pipeline) pipeline) end - def attachment(pipeline) + def large_attachment(pipeline) { - text: "Status: #{pipeline.status}", + fallback: title(pipeline), + title: title(pipeline), + title_link: link(pipeline), color: "#C95823" } end diff --git a/app/services/integrations/project_snippet_service.rb b/app/services/integrations/project_snippet_service.rb index 20eca22d9fda..a363c176ed30 100644 --- a/app/services/integrations/project_snippet_service.rb +++ b/app/services/integrations/project_snippet_service.rb @@ -12,7 +12,7 @@ def find_resource end def title(snippet) - "[$#{snippet.id} #{snippet.title}](#{link(snippet)})" + format("$#{snippet.id} #{snippet.title}") end def link(snippet) @@ -26,5 +26,25 @@ def attachment(snippet) color: '#C95823', } end + + def small_attachment(snippet) + { + fallback: snippet.title, + title: title(snippet), + title_link: link(snippet), + text: snippet.description || "", # Slack doesn't like null + color: '#345' + } + end + + def fields(snippet) + [ + { + title: 'Author', + value: snippet.author, + short: true + } + ] + end end end diff --git a/app/views/projects/integrations/_form.html.haml b/app/views/projects/integrations/_form.html.haml index ddab61210113..1bb1b439996b 100644 --- a/app/views/projects/integrations/_form.html.haml +++ b/app/views/projects/integrations/_form.html.haml @@ -1,18 +1,24 @@ .row.prepend-top-default.append-bottom-default .col-lg-3 %h4.prepend-top-0 - Integrations + Slack Integration (Experimental) %p - Integrations allow services to communicate with GitLab to enrich the experience. - = succeed "." do - = link_to "Read more about integrations", help_page_path("integrations") + -# TODO + In Slack, create a new custom integration, and add a slash commands for each: `/issue`, `/merge-request`, `/pipeline`, and `/snippet`. Enter each token in a new integration here. + + %p + The URL for slack to POST to is: + = integrations_trigger_url + + = succeed "." do + = link_to "Read more about Slack integration", help_page_path("integrations") = form_for [@project.namespace.becomes(Namespace), @project, @integration], html: { class: 'col-lg-9' } do |f| = form_errors(@integration) .form-group = f.label :name, 'Name', class: 'label-light' - = f.text_field :name, required: true, class: 'form-control' + = f.text_field :name, required: true, class: 'form-control', placeholder: 'Slack issue command' .form-group = f.label :external_token, 'External token', class: 'label-light' = f.text_field :external_token, class: 'form-control' diff --git a/app/views/projects/integrations/index.html.haml b/app/views/projects/integrations/index.html.haml index 6d116979368b..3d228b297d87 100644 --- a/app/views/projects/integrations/index.html.haml +++ b/app/views/projects/integrations/index.html.haml @@ -1,6 +1,11 @@ - page_title "Integrations" %h3.page-title Integrations -%p.light Integrations allow other services to request data on this project. + +%p + Project integrations allow GitLab to respond to inbound webhooks from other applications. Currently GitLab has experimental support for Slack slash commands. + += succeed "." do + = link_to "Read more about integrations", help_page_path("integrations") %div - if can?(current_user, :create_integration, @project) @@ -15,7 +20,7 @@ %tr %th Name %th Enabled since - - @integrations.sort_by(&:type).each do |integration| + - @integrations.sort_by(&:name).each do |integration| %tr %td = link_to edit_namespace_project_integration_path(@project.namespace, @project, integration.id) do diff --git a/app/views/projects/integrations/new.html.haml b/app/views/projects/integrations/new.html.haml index ff1dfe4d734b..f9648a20e7b3 100644 --- a/app/views/projects/integrations/new.html.haml +++ b/app/views/projects/integrations/new.html.haml @@ -1,6 +1,6 @@ -- page_title 'New Integration' +- page_title 'New Slack slash command' %h3.page-title - New Integration + New Slack slash command %hr = render 'form' diff --git a/spec/controllers/integrations_controller_spec.rb b/spec/controllers/integrations_controller_spec.rb index 00c1f03690bf..3aad02da4cbc 100644 --- a/spec/controllers/integrations_controller_spec.rb +++ b/spec/controllers/integrations_controller_spec.rb @@ -42,7 +42,7 @@ expect(response).to have_http_status(200) expect(json_response['response_type']).to eq 'in_channel' - expect(json_response['text']).to match /#\d+\s#{Regexp.quote(issue.title)}/ + expect(json_response['attachments']).not_to eq nil end end end diff --git a/spec/services/integrations/issue_service_spec.rb b/spec/services/integrations/issue_service_spec.rb index bf28aacc71da..554495103054 100644 --- a/spec/services/integrations/issue_service_spec.rb +++ b/spec/services/integrations/issue_service_spec.rb @@ -12,14 +12,15 @@ let(:params) { { text: issue.iid } } it 'returns the issue by IID' do - expect(subject[:text]).to match /#\d+\s#{Regexp.quote(issue.title)}/ + expect(subject[:response_type]).to be :in_channel + expect(subject[:attachments].count).to be 1 end context 'the IID is passed as string' do let(:params) { { text: issue.iid.to_s } } it 'returns the issue by IID' do - expect(subject[:text]).to match /#\d+\s#{Regexp.quote(issue.title)}/ + expect(subject[:attachments].first[:fallback]).to match /#{Regexp.quote(issue.title)}/ end end end -- GitLab From 5907ed8cb6398cc46b336ea7bbaf4eb5a00adb7b Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Wed, 21 Sep 2016 00:59:20 +0300 Subject: [PATCH 5/5] incorporate review --- CHANGELOG | 1 + app/controllers/integrations_controller.rb | 23 +++--- app/models/integration.rb | 5 +- app/models/project.rb | 5 +- app/models/project_feature.rb | 12 ++++ app/services/integrations/base_service.rb | 71 +++++++++---------- app/services/integrations/issue_service.rb | 11 +-- .../integrations/merge_request_service.rb | 19 ++--- app/services/integrations/pipeline_service.rb | 34 ++++++--- .../integrations/project_snippet_service.rb | 30 +++----- .../projects/integrations/_form.html.haml | 15 +++- .../20160913092152_create_integrations.rb | 2 + doc/project_services/slack-slash-commands.md | 45 ++++++++++++ .../integrations_controller_spec.rb | 20 +++--- spec/models/integration_spec.rb | 12 +++- .../merge_request_service_spec.rb | 11 ++- .../integrations/pipeline_service_spec.rb | 2 +- .../project_snippet_service_spec.rb | 6 +- 18 files changed, 195 insertions(+), 129 deletions(-) create mode 100644 doc/project_services/slack-slash-commands.md diff --git a/CHANGELOG b/CHANGELOG index 88001b786583..1d4d45d202fb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -18,6 +18,7 @@ v 8.12.0 (unreleased) - Make push events have equal vertical spacing. - API: Ensure invitees are not returned in Members API. - Preserve applied filters on issues search. + - Support for four Slack slash commands !6400 - Add two-factor recovery endpoint to internal API !5510 - Pass the "Remember me" value to the U2F authentication form - Display stages in valid order in stages dropdown on build page diff --git a/app/controllers/integrations_controller.rb b/app/controllers/integrations_controller.rb index bb2705f1ff97..dbd73c8d890e 100644 --- a/app/controllers/integrations_controller.rb +++ b/app/controllers/integrations_controller.rb @@ -1,16 +1,14 @@ class IntegrationsController < ApplicationController respond_to :json - skip_before_filter :verify_authenticity_token + skip_before_action :verify_authenticity_token skip_before_action :authenticate_user! - before_action :integration - def trigger - service = service(params[:command]) + triggered_service = service(integration, params[:command]) - if integration && service - render json: service.new(project, nil, params).execute + if triggered_service + render json: triggered_service.new(project, nil, params).execute else render json: no_integration_found end @@ -33,15 +31,16 @@ def project integration.project end - def service(command) - case command - when '/issue' + def service(integration, command) + return nil unless integration + + if command == '/issue' && project.issues_enabled? && project.default_issues_tracker? Integrations::IssueService - when '/merge-request' + elsif command == '/merge-request' && project.merge_requests_enabled? Integrations::MergeRequestService - when '/pipeline' + elsif command == '/pipeline' && project.builds_enabled? Integrations::PipelineService - when '/snippet' + elsif command == '/snippet' && project.snippets_enabled? Integrations::ProjectSnippetService end end diff --git a/app/models/integration.rb b/app/models/integration.rb index 8e73e8224324..3b373c553368 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -1,3 +1,6 @@ class Integration < ActiveRecord::Base - belongs_to :project + belongs_to :project, required: true, validate: true + + validates :name, presence: true + validates :external_token, presence: true, uniqueness: true end diff --git a/app/models/project.rb b/app/models/project.rb index 990a4ff124ab..b5df7162e8aa 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -17,7 +17,9 @@ class Project < ActiveRecord::Base UNKNOWN_IMPORT_URL = 'http://unknown.git' - 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?, :snippets_enabled?, + to: :project_feature, allow_nil: true default_value_for :archived, false default_value_for :visibility_level, gitlab_config_features.visibility_level @@ -142,7 +144,6 @@ def update_forks_visibility_level has_many :deployments, dependent: :destroy has_many :integrations, dependent: :destroy - accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 8c9534c3565f..358beb658c82 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -52,6 +52,18 @@ def merge_requests_enabled? merge_requests_access_level > DISABLED end + def issues_enabled? + return true unless issues_access_level + + issues_access_level > DISABLED + end + + def snippets_enabled? + return true unless snippets_access_level + + snippets_access_level > DISABLED + end + private def get_permission(user, level) diff --git a/app/services/integrations/base_service.rb b/app/services/integrations/base_service.rb index 4f91dc7ec1cf..5c6f7430c9c5 100644 --- a/app/services/integrations/base_service.rb +++ b/app/services/integrations/base_service.rb @@ -5,7 +5,7 @@ def execute if resource_id find_resource else - collection.search(params[:text]).limit(10) + collection.search(params[:text]).limit(5) end generate_response(resource) @@ -13,32 +13,22 @@ def execute private - def klass - raise NotImplementedError - end - def find_resource collection.find_by(iid: resource_id) end - def title(*args) - raise NotImplementedError + def title(resource) + format("#{resource.title}") end - def link(*args) + def link(resource) raise NotImplementedError end def resource_id - if params[:text].is_a?(Integer) || params[:text].match(/\A\d+\z/) - params[:text].to_i - else - nil - end - end + data = params[:text].to_s.match(/\A(\$|#|!)?(\d+)\z/) - def fallback(*args) - raise NotImplementedError + data ? data[2].to_i : nil end def collection @@ -46,22 +36,15 @@ def collection end def generate_response(resource) - if resource.nil? - respond_404 - elsif resource.respond_to?(:count) - return generate_response(resource.first) if resource.count == 1 - return no_search_results if resource.empty? + return respond_404 if resource.nil? + return single_resource(resource) unless resource.respond_to?(:count) - { - response_type: :ephemeral, - text: "Search results for #{params[:text]}", - attachments: resource.map { |item| small_attachment(item) } - } + if resource.empty? + no_search_results + elsif resource.count == 1 + single_resource(resource.first) else - { - response_type: :in_channel, - attachments: [ large_attachment(resource) ] - } + multiple_resources(resource) end end @@ -71,11 +54,26 @@ def slack_format(message) def no_search_results { - text: "No search results for #{params[:text]}. :(", + text: "No search results for \"#{params[:text]}\". :disappointed:", response_type: :ephemeral } end + def single_resource(resource) + { + response_type: :in_channel, + attachments: [ large_attachment(resource) ] + } + end + + def multiple_resources(resources) + { + response_type: :ephemeral, + text: "Search results for \"#{params[:text]}\"", + attachments: resources.map { |item| small_attachment(item) } + } + end + def respond_404 { text: "404 not found! Please make you use the right identifier. :boom:", @@ -93,17 +91,16 @@ def small_attachment(issuable) title: title(issuable), title_link: link(issuable), text: issuable.description || "", # Slack doesn't like null - color: "#C95823" } end def fields(issuable) result = [ - { - title: 'Author', - value: issuable.author.name, - short: true - } + { + title: 'Author', + value: issuable.author.name, + short: true + } ] if issuable.assignee diff --git a/app/services/integrations/issue_service.rb b/app/services/integrations/issue_service.rb index b00901afe3d9..d1f53314dd41 100644 --- a/app/services/integrations/issue_service.rb +++ b/app/services/integrations/issue_service.rb @@ -1,14 +1,9 @@ module Integrations - class IssueService < BaseService - + class IssueService < Integrations::BaseService private - def klass - Issue - end - - def title(issue) - format("##{issue.iid} #{issue.title}") + def collection + project.issues end def link(issue) diff --git a/app/services/integrations/merge_request_service.rb b/app/services/integrations/merge_request_service.rb index 6d8b3485cb59..4bec4b68c8a8 100644 --- a/app/services/integrations/merge_request_service.rb +++ b/app/services/integrations/merge_request_service.rb @@ -1,24 +1,15 @@ module Integrations - class MergeRequestService < BaseService - + class MergeRequestService < Integrations::BaseService private - def klass - MergeRequest - end - def collection - klass.where(target_project: project) - end - - def title(merge_request) - format("!#{merge_request.iid} #{merge_request.title}") + project.merge_requests end def link(merge_request) - Gitlab::Routing.url_helpers.namespace_project_merge_request_url(project.namespace, - project, - merge_request) + Gitlab::Routing. + url_helpers + .namespace_project_merge_request_url(project.namespace, project, merge_request) end end end diff --git a/app/services/integrations/pipeline_service.rb b/app/services/integrations/pipeline_service.rb index d4562fe6ecd8..543d85a4b8f0 100644 --- a/app/services/integrations/pipeline_service.rb +++ b/app/services/integrations/pipeline_service.rb @@ -1,6 +1,5 @@ module Integrations - class PipelineService < BaseService - + class PipelineService < Integrations::BaseService def execute resource = if resource_id @@ -15,19 +14,15 @@ def execute private def merge_request_pipeline(iid) - project.merge_requests.find_by(iid: iid).pipeline + project.merge_requests.find_by(iid: iid).try(:pipeline) end def pipeline_by_ref(ref) project.pipelines.where(ref: ref).last end - def klass - Pipeline - end - def title(pipeline) - "##{pipeline.id} Pipeline for #{pipeline.ref}: #{pipeline.status}" + "Pipeline for #{pipeline.ref}: #{pipeline.status}" end def link(pipeline) @@ -41,8 +36,29 @@ def large_attachment(pipeline) fallback: title(pipeline), title: title(pipeline), title_link: link(pipeline), - color: "#C95823" + fields: [ + fields(pipeline) + ] } end + + def fields(pipeline) + commit = pipeline.commit + + return [] unless commit + + [ + { + title: 'Author', + value: commit.author.name, + short: true + }, + { + title: 'Commit Title', + value: commit.title, + short: true + } + ] + end end end diff --git a/app/services/integrations/project_snippet_service.rb b/app/services/integrations/project_snippet_service.rb index a363c176ed30..89a46cb91227 100644 --- a/app/services/integrations/project_snippet_service.rb +++ b/app/services/integrations/project_snippet_service.rb @@ -1,30 +1,19 @@ module Integrations - class ProjectSnippetService < BaseService - + class ProjectSnippetService < Integrations::BaseService private - def klass - ProjectSnippet + def collection + project.snippets end def find_resource - collection.find_by(id: params[:text]) - end - - def title(snippet) - format("$#{snippet.id} #{snippet.title}") + collection.find_by(id: resource_id) end def link(snippet) - Gitlab::Routing.url_helpers.namespace_project_snippet_url(project.namespace, - project, snippet) - end - - def attachment(snippet) - { - text: slack_format(snippet.content), - color: '#C95823', - } + Gitlab::Routing. + url_helpers. + namespace_project_snippet_url(project.namespace, project, snippet) end def small_attachment(snippet) @@ -32,8 +21,7 @@ def small_attachment(snippet) fallback: snippet.title, title: title(snippet), title_link: link(snippet), - text: snippet.description || "", # Slack doesn't like null - color: '#345' + text: snippet.content || "", # Slack doesn't like null } end @@ -41,7 +29,7 @@ def fields(snippet) [ { title: 'Author', - value: snippet.author, + value: snippet.author.name, short: true } ] diff --git a/app/views/projects/integrations/_form.html.haml b/app/views/projects/integrations/_form.html.haml index 1bb1b439996b..8bc400c197ec 100644 --- a/app/views/projects/integrations/_form.html.haml +++ b/app/views/projects/integrations/_form.html.haml @@ -3,11 +3,20 @@ %h4.prepend-top-0 Slack Integration (Experimental) %p - -# TODO - In Slack, create a new custom integration, and add a slash commands for each: `/issue`, `/merge-request`, `/pipeline`, and `/snippet`. Enter each token in a new integration here. + In Slack, create a new custom integration, and add a slash command for each: + = succeed ',' do + %code /issue + = succeed ',' do + %code /merge-request + = succeed ',' do + %code /pipeline + = succeed '.' do + %code /snippet + + Enter each token in a new integration here. %p - The URL for slack to POST to is: + The URL for Slack to POST to is: = integrations_trigger_url = succeed "." do diff --git a/db/migrate/20160913092152_create_integrations.rb b/db/migrate/20160913092152_create_integrations.rb index 04ece0be8b2f..4ac8f2811996 100644 --- a/db/migrate/20160913092152_create_integrations.rb +++ b/db/migrate/20160913092152_create_integrations.rb @@ -1,4 +1,6 @@ class CreateIntegrations < ActiveRecord::Migration + DOWNTIME = false + def change create_table :integrations do |t| t.references :project, index: true, foreign_key: true diff --git a/doc/project_services/slack-slash-commands.md b/doc/project_services/slack-slash-commands.md new file mode 100644 index 000000000000..2451055a4c9f --- /dev/null +++ b/doc/project_services/slack-slash-commands.md @@ -0,0 +1,45 @@ +# Slack slash commands + +**NOTE:** At this time Slack slash commands for GitLab are experimental are it is +likely to change in the future. + +When using Slack you could already use the [Slack service](slack.md) to +notify your team on events on your GitLab instance. With Slack slash commands there +is now a way for you to request data from GitLab. + +GitLab provides support for `/issue`, `/merge-request`, `/pipeline`, and `/snippet` +commands. + +## Configuration + +To enable a slash command for your project, visit the **Integrations** page on your project. +When creating a new integration you will have to list + + +For configuring Slack slash commands for your project, you will have to register +a custom slash command for your Slack app. + + +A GitLab administrator can add a service template that sets a default for each +project. This makes it much easier to configure individual projects. + +After the template is created, the template details will be pre-filled on a +project's Service page. + +## Enable a Service template + +In GitLab's Admin area, navigate to **Service Templates** and choose the +service template you wish to create. + +For example, in the image below you can see Redmine. + +![Redmine service template](img/services_templates_redmine_example.png) + + + + + +--- + + +[slack-service-docs]: ./slack diff --git a/spec/controllers/integrations_controller_spec.rb b/spec/controllers/integrations_controller_spec.rb index 3aad02da4cbc..53f4619c9838 100644 --- a/spec/controllers/integrations_controller_spec.rb +++ b/spec/controllers/integrations_controller_spec.rb @@ -6,16 +6,16 @@ let(:slack_params) do { format: :json, - "token"=>"24randomcharacters", - "team_id"=>"T123456T9", - "team_domain"=>"mepmep", - "channel_id"=>"C12345678", - "channel_name"=>"general", - "user_id"=>"U12345678", - "user_name"=>"mep", - "command"=>"/issue", - "text"=>"3", - "response_url"=>"https://hooks.slack.com/commands/T123456T9/79958163905/siWqY7Qtx8z0zWFsXBod9VEy" + "token" => "24randomcharacters", + "team_id" => "T123456T9", + "team_domain" => "mepmep", + "channel_id" => "C12345678", + "channel_name" => "general", + "user_id" => "U12345678", + "user_name" => "mep", + "command" => "/issue", + "text" => "3", + "response_url" => "https://hooks.slack.com/commands/T123456T9/79958163905/siWqY7Qtx8z0zWFsXBod9VEy" } end diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb index aae8d31dae1f..b8aa4acb211a 100644 --- a/spec/models/integration_spec.rb +++ b/spec/models/integration_spec.rb @@ -1,5 +1,15 @@ require 'rails_helper' RSpec.describe Integration, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + subject { create(:integration) } + + describe 'associations' do + it { is_expected.to belong_to(:project) } + end + + describe 'validation' do + it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:external_token) } + end end diff --git a/spec/services/integrations/merge_request_service_spec.rb b/spec/services/integrations/merge_request_service_spec.rb index 9e4e5f21951f..d42829ecd24e 100644 --- a/spec/services/integrations/merge_request_service_spec.rb +++ b/spec/services/integrations/merge_request_service_spec.rb @@ -12,19 +12,16 @@ let(:mr) { create(:merge_request, source_project: project) } it 'returns the issue by ID' do - expect(subject[:text]).to match /!\d+\s#{Regexp.quote(mr.title)}/ + expect(subject[:attachments].first[:fallback]).to eq mr.title end end context 'when searching with only one result' do - let(:title) { 'Aint this a String?' } - let(:params) { { text: title[2..7] } } + let(:params) { { text: merge_request.title[2..7] } } + let!(:merge_request) { create(:merge_request, source_project: project) } it 'returns the search results' do - create(:merge_request, source_project: project, title: title) - create(:merge_request, source_project: project) - - expect(subject[:text]).to match /!\d+\sAint\sthis/ + expect(subject[:attachments].first[:fallback]).to eq merge_request.title end end end diff --git a/spec/services/integrations/pipeline_service_spec.rb b/spec/services/integrations/pipeline_service_spec.rb index 1e4a2561f8c2..19e0d5ed5be0 100644 --- a/spec/services/integrations/pipeline_service_spec.rb +++ b/spec/services/integrations/pipeline_service_spec.rb @@ -12,7 +12,7 @@ let(:params) { { text: pipeline.ref } } it 'returns the pipeline by ID' do - expect(subject[:text]).to match /#\d+\ Pipeline\ for\ #{pipeline.ref}: #{pipeline.status}/ + expect(subject[:attachments].first[:fallback]).to match /Pipeline\ for\ #{pipeline.ref}: #{pipeline.status}/ end end end diff --git a/spec/services/integrations/project_snippet_service_spec.rb b/spec/services/integrations/project_snippet_service_spec.rb index 8d2a16ac815a..6efb4a09152c 100644 --- a/spec/services/integrations/project_snippet_service_spec.rb +++ b/spec/services/integrations/project_snippet_service_spec.rb @@ -9,10 +9,10 @@ describe '#execute' do context 'looking up by ID' do let(:snippet) { create(:project_snippet, project: project) } - let(:params) { { text: snippet.id } } + let(:params) { { text: "$#{snippet.id}" } } - it 'returns the issue by ID' do - expect(subject[:text]).to match /\$\d+\s#{Regexp.quote(snippet.title)}/ + it 'returns the snippet by ID' do + expect(subject[:attachments].first[:fallback]).to eq snippet.title end end end -- GitLab