diff --git a/.gitlab/ci/browser-core.yml b/.gitlab/ci/browser-core.yml index 7175ee151..e4a01af1b 100644 --- a/.gitlab/ci/browser-core.yml +++ b/.gitlab/ci/browser-core.yml @@ -70,7 +70,7 @@ include: RAILS_ENV: "test" script: - bundle exec rake zammad:ci:test:prepare - - bundle exec rspec --fail-fast -t type:system + - bundle exec rspec --fail-fast -t type:system -t ~integration # we need at least one job to store and include this template # $IGNORE is not defined diff --git a/.gitlab/ci/browser-integration.yml b/.gitlab/ci/browser-integration.yml index 6314e3b34..39e8f179f 100644 --- a/.gitlab/ci/browser-integration.yml +++ b/.gitlab/ci/browser-integration.yml @@ -1,12 +1,29 @@ include: # browser-integration + - local: '/.gitlab/ci/browser-integration/capybara_chrome.yml' + - local: '/.gitlab/ci/browser-integration/capybara_ff.yml' - local: '/.gitlab/ci/browser-integration/facebook_chrome.yml' - local: '/.gitlab/ci/browser-integration/facebook_ff.yml' - local: '/.gitlab/ci/browser-integration/idoit_chrome.yml' - local: '/.gitlab/ci/browser-integration/otrs_chrome.yml' - local: '/.gitlab/ci/browser-integration/zendesk_chrome.yml' +.template_browser-integration_capybara: &template_browser-integration_capybara + stage: browser-integration + dependencies: + - browser:build + extends: + - .env_base + - .variables_app_restart_cmd + - .variables_es + - .services_mysql_postgresql_elasticsearch_selenium_imap + variables: + RAILS_ENV: "test" + script: + - bundle exec rake zammad:ci:test:prepare + - bundle exec rspec --fail-fast --pattern "spec/system/**/*_spec.rb" -t integration + .template_browser-integration: &template_browser-integration stage: browser-integration dependencies: diff --git a/.gitlab/ci/browser-integration/capybara_chrome.yml b/.gitlab/ci/browser-integration/capybara_chrome.yml new file mode 100644 index 000000000..c1d4c877b --- /dev/null +++ b/.gitlab/ci/browser-integration/capybara_chrome.yml @@ -0,0 +1,5 @@ +integration_capybara_chrome: + extends: + - .template_browser-integration_capybara + variables: + BROWSER: "chrome" diff --git a/.gitlab/ci/browser-integration/capybara_ff.yml b/.gitlab/ci/browser-integration/capybara_ff.yml new file mode 100644 index 000000000..e00b99b41 --- /dev/null +++ b/.gitlab/ci/browser-integration/capybara_ff.yml @@ -0,0 +1,5 @@ +integration_capybara_ff: + extends: + - .template_browser-integration_capybara + variables: + BROWSER: "firefox" diff --git a/.rubocop/todo.rspec.yml b/.rubocop/todo.rspec.yml index 78d3e54a9..f8448ee64 100644 --- a/.rubocop/todo.rspec.yml +++ b/.rubocop/todo.rspec.yml @@ -274,6 +274,7 @@ RSpec/ExampleLength: - 'spec/requests/integration/check_mk_spec.rb' - 'spec/requests/integration/cti_spec.rb' - 'spec/requests/integration/idoit_spec.rb' + - 'spec/requests/integration/gitlab_spec.rb' - 'spec/requests/integration/monitoring_spec.rb' - 'spec/requests/integration/object_manager_attributes_spec.rb' - 'spec/requests/integration/placetel_spec.rb' @@ -554,6 +555,7 @@ RSpec/MultipleExpectations: - 'spec/requests/integration/check_mk_spec.rb' - 'spec/requests/integration/cti_spec.rb' - 'spec/requests/integration/idoit_spec.rb' + - 'spec/requests/integration/gitlab_spec.rb' - 'spec/requests/integration/monitoring_spec.rb' - 'spec/requests/integration/object_manager_attributes_spec.rb' - 'spec/requests/integration/placetel_spec.rb' diff --git a/Gemfile b/Gemfile index 032282dee..679f3843a 100644 --- a/Gemfile +++ b/Gemfile @@ -124,6 +124,7 @@ gem 'acts_as_list' # integrations gem 'clearbit' +gem 'graphql-client' gem 'net-ldap' gem 'slack-notifier' gem 'zendesk_api' diff --git a/Gemfile.lock b/Gemfile.lock index 739bb18bd..1f61e1a84 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -218,6 +218,10 @@ GEM activesupport (>= 4.2.0) gmail_xoauth (0.4.2) oauth (>= 0.3.6) + graphql (1.11.6) + graphql-client (0.16.0) + activesupport (>= 3.0) + graphql (~> 1.8) guard (2.15.0) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) @@ -613,6 +617,7 @@ DEPENDENCIES faker github_changelog_generator gmail_xoauth + graphql-client guard guard-livereload guard-symlink diff --git a/LICENSE-ICONS-3RD-PARTY.json b/LICENSE-ICONS-3RD-PARTY.json index c36e26b64..56779c6db 100644 --- a/LICENSE-ICONS-3RD-PARTY.json +++ b/LICENSE-ICONS-3RD-PARTY.json @@ -225,7 +225,7 @@ "license": "" }, "gitlab-button.svg": { - "author": "Gitlab", + "author": "GitLab", "url": "", "license": "" }, @@ -694,4 +694,4 @@ "url": "https://thenounproject.com/term/key/1247931/", "license": "CC 3.0 Attribution" } -} \ No newline at end of file +} diff --git a/app/assets/javascripts/app/controllers/_integration/gitlab.coffee b/app/assets/javascripts/app/controllers/_integration/gitlab.coffee new file mode 100644 index 000000000..223905ca0 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_integration/gitlab.coffee @@ -0,0 +1,82 @@ +class GitLab extends App.ControllerIntegrationBase + featureIntegration: 'gitlab_integration' + featureName: 'GitLab' + featureConfig: 'gitlab_config' + description: [ + ['This service allows you to connect %s with %s.', 'GitLab', 'Zammad'] + ] + events: + 'change .js-switch input': 'switch' + + render: => + super + new Form( + el: @$('.js-form') + ) + +class Form extends App.Controller + events: + 'submit form': 'update' + + constructor: -> + super + @render() + + render: => + config = App.Setting.get('gitlab_config') + + @html App.view('integration/gitlab')( + config: config + ) + + update: (e) => + e.preventDefault() + config = @formParam(e.target) + @validateAndSave(config) + + validateAndSave: (config) => + App.Ajax.request( + id: 'gitlab' + type: 'POST' + url: "#{@apiPath}/integration/gitlab/verify" + data: JSON.stringify( + api_token: config.api_token + endpoint: config.endpoint + ) + success: (data, status, xhr) => + if data.result is 'failed' + new App.ControllerErrorModal( + message: data.message + container: @el.closest('.content') + ) + return + + config.schema = data.response + App.Setting.set('gitlab_config', config) + + error: (data, status) -> + + return if status is 'abort' + + details = data.responseJSON || {} + App.Event.trigger 'notify', { + type: 'error' + msg: App.i18n.translateContent(details.error_human || details.error || 'Unable to save!') + } + ) + +class State + @current: -> + App.Setting.get('gitlab_integration') + +App.Config.set( + 'IntegrationGitLab' + { + name: 'GitLab' + target: '#system/integration/gitlab' + description: 'Link GitLab issues to your tickets.' + controller: GitLab + state: State + } + 'NavBarIntegrations' +) diff --git a/app/assets/javascripts/app/controllers/git_issue_link_modal.coffee b/app/assets/javascripts/app/controllers/git_issue_link_modal.coffee new file mode 100644 index 000000000..be221c358 --- /dev/null +++ b/app/assets/javascripts/app/controllers/git_issue_link_modal.coffee @@ -0,0 +1,23 @@ +class App.GitIssueLinkModal extends App.ControllerModal + buttonClose: true + buttonCancel: true + buttonSubmit: true + + constructor: (params) -> + @placeholder = params.placeholder + @head = params.head + super + + content: -> + $(App.view('integration/git_issue_link_modal')( + placeholder: @placeholder + )) + + onSubmit: (e) => + form = @el.find('.js-result') + params = @formParam(form) + return if _.isEmpty(params.link) + + @formDisable(form) + @callback(params.link, @) + diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_git_issue.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_git_issue.coffee new file mode 100644 index 000000000..9590e4461 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_git_issue.coffee @@ -0,0 +1,167 @@ +class App.SidebarGitIssue extends App.Controller + provider: '_need_to_be_defined_' # GitLab + urlPlaceholder: '_need_to_be_defined_' # https://git.example.com/group1/project1/-/issues/1 + + constructor: -> + super + @issueLinks = [] + @providerIdentifier = @provider.toLowerCase() + + sidebarItem: => + return if !@Config.get("#{@providerIdentifier}_integration") + @item = { + name: @providerIdentifier + badgeCallback: @badgeRender + sidebarHead: @provider + sidebarCallback: @showObjects + sidebarActions: [ + { + title: 'Link issue' + name: 'link-issue' + callback: @linkIssue + }, + ] + } + @item + + shown: -> + return if !@ticket + return if !@ticket.id + @showIssues() + + metaBadge: => + counter = '' + counter = @issueLinks.length + + { + name: 'customer' + icon: "#{@providerIdentifier}-logo" + counterPossible: true + counter: counter + } + + badgeRender: (el) => + @badgeEl = el + @badgeRenderLocal() + + badgeRenderLocal: => + @badgeEl.html(App.view('generic/sidebar_tabs_item')(@metaBadge())) + + linkIssue: => + new App.GitIssueLinkModal( + head: @provider + placeholder: @urlPlaceholder + taskKey: @taskKey + container: @el.closest('.content') + callback: (link, ui) => + if @ticket && @ticket.id + @saveTicketIssues = true + ui.close() + @showIssues([link]) + ) + + showObjects: (el) => + @el = el + + # show placeholder + if @ticket && @ticket.preferences && @ticket.preferences[@providerIdentifier] && @ticket.preferences[@providerIdentifier].issue_links + @issueLinks = @ticket.preferences[@providerIdentifier].issue_links + queryParams = @queryParam() + + # TODO: what is 'gitlab_issue_links' + if queryParams && queryParams.gitlab_issue_links + @issueLinks.push queryParams.gitlab_issue_links + @showIssues() + + showIssues: (issueLinks) => + if issueLinks + @issueLinks = _.uniq(@issueLinks.concat(issueLinks)) + + # show placeholder + if _.isEmpty(@issueLinks) + @html("
#{App.i18n.translateInline('No linked issues')}
") + return + + # AJAX call to show items + @ajax( + id: "#{@providerIdentifier}-#{@taskKey}" + type: 'POST' + url: "#{@apiPath}/integration/#{@providerIdentifier}" + data: JSON.stringify(links: @issueLinks) + success: (data, status, xhr) => + if data.response + @showList(data.response) + if @saveTicketIssues + @saveTicketIssues = false + @issueLinks = data.response.map((issue) -> issue.url) + @updateTicket(@ticket.id, @issueLinks) + return + @showError('Unable to load data...') + + error: (xhr, status, error) => + + # do not close window if request is aborted + return if status is 'abort' + + # show error message + @showError('Unable to load data...') + ) + + showList: (issues) => + list = $(App.view('ticket_zoom/sidebar_git_issue')( + issues: issues + )) + list.delegate('.js-delete', 'click', (e) => + e.preventDefault() + issueLink = $(e.currentTarget).attr 'data-issue-id' + @delete(issueLink) + ) + @html(list) + @badgeRenderLocal() + + showError: (message) => + @html App.i18n.translateInline(message) + + reload: => + @showIssues() + + delete: (issueLink) => + localLinks = [] + for localLink in @issueLinks + if issueLink.toString() isnt localLink.toString() + localLinks.push localLink + @issueLinks = localLinks + if @ticket && @ticket.id + @updateTicket(@ticket.id, @issueLinks) + @showIssues() + + postParams: (args) => + return if !args.ticket + return if args.ticket.created_at + return if !@issueLinks + return if _.isEmpty(@issueLinks) + args.ticket.preferences ||= {} + args.ticket.preferences[@providerIdentifier] ||= {} + args.ticket.preferences[@providerIdentifier].issue_links = @issueLinks + + updateTicket: (ticket_id, issueLinks) => + App.Ajax.request( + id: "#{@providerIdentifier}-update-#{ticket_id}" + type: 'POST' + url: "#{@apiPath}/integration/#{@providerIdentifier}_ticket_update" + data: JSON.stringify(ticket_id: ticket_id, issue_links: issueLinks) + success: (data, status, xhr) => + @badgeRenderLocal() + error: (xhr, status, details) => + + # do not close window if request is aborted + return if status is 'abort' + + # show error message + @log 'errors', details + @notify( + type: 'error' + msg: App.i18n.translateContent(details.error_human || details.error || 'Unable to update object!') + timeout: 6000 + ) + ) diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_gitlab.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_gitlab.coffee new file mode 100644 index 000000000..a8d939b56 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_gitlab.coffee @@ -0,0 +1,6 @@ +class SidebarGitLab extends App.SidebarGitIssue + provider: 'GitLab' + urlPlaceholder: 'https://git.example.com/group1/project1/-/issues/1' + +App.Config.set('500-GitLab', SidebarGitLab, 'TicketCreateSidebar') +App.Config.set('500-GitLab', SidebarGitLab, 'TicketZoomSidebar') diff --git a/app/assets/javascripts/app/controllers/widget/sidebar.coffee b/app/assets/javascripts/app/controllers/widget/sidebar.coffee index b67ffc6e2..88ad4071e 100644 --- a/app/assets/javascripts/app/controllers/widget/sidebar.coffee +++ b/app/assets/javascripts/app/controllers/widget/sidebar.coffee @@ -122,6 +122,12 @@ class App.Sidebar extends App.Controller # remember current tab @currentTab = name + # get current sidebar controller + for item in @items + itemLocal = item.sidebarItem() + if itemLocal && itemLocal.name && itemLocal.name is @currentTab && item.shown + item.shown() + # show sidebar if not shown @showSidebar() diff --git a/app/assets/javascripts/app/views/generic/sidebar_tabs_item.jst.eco b/app/assets/javascripts/app/views/generic/sidebar_tabs_item.jst.eco index 881b44273..c3c015359 100644 --- a/app/assets/javascripts/app/views/generic/sidebar_tabs_item.jst.eco +++ b/app/assets/javascripts/app/views/generic/sidebar_tabs_item.jst.eco @@ -1,4 +1,4 @@ -<% if @counterPossible is true: %> -
<%= @counter %>
+<% if @counterPossible is true and @counter and @counter > 0: %> +
<%= @counter %>
<% end %> <%- @Icon(@icon) %> \ No newline at end of file diff --git a/app/assets/javascripts/app/views/integration/git_issue_link_modal.jst.eco b/app/assets/javascripts/app/views/integration/git_issue_link_modal.jst.eco new file mode 100644 index 000000000..6d9734423 --- /dev/null +++ b/app/assets/javascripts/app/views/integration/git_issue_link_modal.jst.eco @@ -0,0 +1,3 @@ +
+ +
diff --git a/app/assets/javascripts/app/views/integration/gitlab.jst.eco b/app/assets/javascripts/app/views/integration/gitlab.jst.eco new file mode 100644 index 000000000..3498b03d4 --- /dev/null +++ b/app/assets/javascripts/app/views/integration/gitlab.jst.eco @@ -0,0 +1,22 @@ +
+

<%- @T('Settings') %>

+
+ + + + + + + + +
<%- @T('Name') %> + <%- @T('Value') %> +
<%- @T('Endpoint') %> * + +
<%- @T('API token') %> * + +
+
+ + +
diff --git a/app/assets/javascripts/app/views/integration/idoit.jst.eco b/app/assets/javascripts/app/views/integration/idoit.jst.eco index deec7f27f..31723df9e 100644 --- a/app/assets/javascripts/app/views/integration/idoit.jst.eco +++ b/app/assets/javascripts/app/views/integration/idoit.jst.eco @@ -8,12 +8,12 @@ <%- @T('Value') %> - - <%- @T('API token') %> * - <%- @T('Endpoint') %> * + + <%- @T('API token') %> * + <%- @T('Client ID') %> @@ -22,4 +22,4 @@ - \ No newline at end of file + diff --git a/app/assets/javascripts/app/views/ticket_zoom/sidebar_git_issue.jst.eco b/app/assets/javascripts/app/views/ticket_zoom/sidebar_git_issue.jst.eco new file mode 100644 index 000000000..a13e624be --- /dev/null +++ b/app/assets/javascripts/app/views/ticket_zoom/sidebar_git_issue.jst.eco @@ -0,0 +1,36 @@ +<% for issue in @issues: %> + + +
+<% end %> diff --git a/app/assets/stylesheets/svg-dimensions.css b/app/assets/stylesheets/svg-dimensions.css index 10d16a36e..0bebf8c11 100644 --- a/app/assets/stylesheets/svg-dimensions.css +++ b/app/assets/stylesheets/svg-dimensions.css @@ -44,6 +44,7 @@ .icon-full-logo { width: 175px; height: 50px; } .icon-github-button { width: 29px; height: 24px; } .icon-gitlab-button { width: 29px; height: 24px; } +.icon-gitlab-logo { width: 24px; height: 24px; } .icon-google-button { width: 29px; height: 24px; } .icon-group { width: 24px; height: 24px; } .icon-help { width: 16px; height: 16px; } diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 088eb7855..fdb56649b 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -5108,6 +5108,15 @@ footer { } } +.sidebar-git-issue-delete { + text-align: right; + float: right; +} + +.sidebar-git-issue-content { + width: 90%; +} + .main + .sidebar { border-right: none; border-left: 1px solid #e6e6e6; diff --git a/app/controllers/integration/gitlab_controller.rb b/app/controllers/integration/gitlab_controller.rb new file mode 100644 index 000000000..abc2e8e61 --- /dev/null +++ b/app/controllers/integration/gitlab_controller.rb @@ -0,0 +1,53 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class Integration::GitLabController < ApplicationController + prepend_before_action { authentication_check && authorize! } + + def verify + gitlab = ::GitLab.new(params[:endpoint], params[:api_token]) + render json: { + result: 'ok', + response: gitlab.schema.to_json, + } + rescue => e + logger.error e + + render json: { + result: 'failed', + message: e.message, + } + end + + def query + config = Setting.get('gitlab_config') + + gitlab = ::GitLab.new(config['endpoint'], config['api_token'], schema: config['schema']) + + render json: { + result: 'ok', + response: gitlab.issues_by_urls(params[:links]), + } + rescue => e + logger.error e + + render json: { + result: 'failed', + message: e.message, + } + end + + def update + ticket = Ticket.find(params[:ticket_id]) + ticket.with_lock do + authorize!(ticket, :show?) + ticket.preferences[:gitlab] ||= {} + ticket.preferences[:gitlab][:issue_links] = Array(params[:issue_links]).uniq + ticket.save! + end + + render json: { + result: 'ok', + } + end + +end diff --git a/app/policies/controllers/integration/gitlab_controller_policy.rb b/app/policies/controllers/integration/gitlab_controller_policy.rb new file mode 100644 index 000000000..13b9cca5b --- /dev/null +++ b/app/policies/controllers/integration/gitlab_controller_policy.rb @@ -0,0 +1,5 @@ +class Controllers::Integration::GitLabControllerPolicy < Controllers::ApplicationControllerPolicy + permit! %i[query update], to: 'ticket.agent' + permit! :verify, to: 'admin.integration.gitlab' + default_permit!(['agent.integration.gitlab', 'admin.integration.gitlab']) +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 87061c42a..6987cc812 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -21,4 +21,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| # see: KnowledgeBase.table_name.singularize inflect.singular(/(knowledge_base)s$/i, '\1') inflect.acronym 'SMIME' + inflect.acronym 'GitLab' end diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index ed8e270f5..b8903d95f 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -32,7 +32,7 @@ Rails.application.config.middleware.use OmniAuth::Builder do provider :github_database, 'not_change_will_be_set_by_database', 'not_change_will_be_set_by_database' # gitlab database connect - provider :gitlab_database, 'not_change_will_be_set_by_database', 'not_change_will_be_set_by_database', { + provider :git_lab_database, 'not_change_will_be_set_by_database', 'not_change_will_be_set_by_database', { client_options: { site: 'https://not_change_will_be_set_by_database', authorize_url: '/oauth/authorize', diff --git a/config/routes/integration_gitlab.rb b/config/routes/integration_gitlab.rb new file mode 100644 index 000000000..7aaea77d0 --- /dev/null +++ b/config/routes/integration_gitlab.rb @@ -0,0 +1,9 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/integration/gitlab', to: 'integration/gitlab#query', via: :post + match api_path + '/integration/gitlab', to: 'integration/gitlab#query', via: :get + match api_path + '/integration/gitlab/verify', to: 'integration/gitlab#verify', via: :post + match api_path + '/integration/gitlab_ticket_update', to: 'integration/gitlab#update', via: :post + +end diff --git a/contrib/icon-sprite.sketch b/contrib/icon-sprite.sketch index 6f00dab67..2c4bb6f60 100644 Binary files a/contrib/icon-sprite.sketch and b/contrib/icon-sprite.sketch differ diff --git a/db/migrate/20190903165443_issue_2595_gitlab_placeholder.rb b/db/migrate/20190903165443_issue_2595_gitlab_placeholder.rb index fbba634a2..0e2e858fd 100644 --- a/db/migrate/20190903165443_issue_2595_gitlab_placeholder.rb +++ b/db/migrate/20190903165443_issue_2595_gitlab_placeholder.rb @@ -1,4 +1,4 @@ -class Issue2595GitlabPlaceholder < ActiveRecord::Migration[5.2] +class Issue2595GitLabPlaceholder < ActiveRecord::Migration[5.2] def change # return if it's a new setup return if !Setting.exists?(name: 'system_init_done') diff --git a/db/migrate/20210113000001_gitlab_support.rb b/db/migrate/20210113000001_gitlab_support.rb new file mode 100644 index 000000000..e1b82cb0c --- /dev/null +++ b/db/migrate/20210113000001_gitlab_support.rb @@ -0,0 +1,51 @@ +class GitLabSupport < ActiveRecord::Migration[4.2] + def up + + # return if it's a new setup + return if !Setting.exists?(name: 'system_init_done') + + Setting.create_if_not_exists( + title: 'GitLab integration', + name: 'gitlab_integration', + area: 'Integration::Switch', + description: 'Defines if the GitLab (http://www.gitlab.com) integration is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'gitlab_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true + ) + Setting.create_if_not_exists( + title: 'GitLab config', + name: 'gitlab_config', + area: 'Integration::GitLab', + description: 'Stores the GitLab configuration.', + options: {}, + state: { + endpoint: 'https://gitlab.com/api/graphql', + }, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, + ) + end + +end diff --git a/db/seeds/settings.rb b/db/seeds/settings.rb index 0156000be..cd1670a1c 100644 --- a/db/seeds/settings.rb +++ b/db/seeds/settings.rb @@ -1427,18 +1427,18 @@ Setting.create_if_not_exists( preferences: { controller: 'SettingsAreaSwitch', sub: ['auth_gitlab_credentials'], - title_i18n: ['Gitlab'], - description_i18n: ['Gitlab', 'Gitlab Applications', 'https://your-gitlab-host/admin/applications'], + title_i18n: ['GitLab'], + description_i18n: ['GitLab', 'GitLab Applications', 'https://your-gitlab-host/admin/applications'], permission: ['admin.security'], }, state: false, frontend: true ) Setting.create_if_not_exists( - title: 'Gitlab App Credentials', + title: 'GitLab App Credentials', name: 'auth_gitlab_credentials', - area: 'Security::ThirdPartyAuthentication::Gitlab', - description: 'Enables user authentication via Gitlab.', + area: 'Security::ThirdPartyAuthentication::GitLab', + description: 'Enables user authentication via GitLab.', options: { form: [ { @@ -4046,6 +4046,48 @@ Setting.create_if_not_exists( }, frontend: false, ) +Setting.create_if_not_exists( + title: 'GitLab integration', + name: 'gitlab_integration', + area: 'Integration::Switch', + description: 'Defines if the GitLab (http://www.gitlab.com) integration is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'gitlab_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'GitLab config', + name: 'gitlab_config', + area: 'Integration::GitLab', + description: 'Stores the GitLab configuration.', + options: {}, + state: { + endpoint: 'https://gitlab.com/api/graphql', + }, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) Setting.create_if_not_exists( title: 'Defines sync transaction backend.', name: '0100_trigger', diff --git a/lib/gitlab.rb b/lib/gitlab.rb new file mode 100644 index 000000000..81d96aa05 --- /dev/null +++ b/lib/gitlab.rb @@ -0,0 +1,27 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class GitLab + extend Forwardable + + attr_reader :client + + def_delegator :client, :schema + + def initialize(*args, **kargs) + @client = GitLab::Client.new(*args, **kargs) + end + + def issues_by_urls(urls) + urls.uniq.each_with_object([]) do |url, result| + issue = issue_by_url(url) + next if issue.blank? + + result << issue + end + end + + def issue_by_url(url) + issue = GitLab::LinkedIssue.new(client) + issue.find_by(url)&.to_h + end +end diff --git a/lib/gitlab/client.rb b/lib/gitlab/client.rb new file mode 100644 index 000000000..7fc371361 --- /dev/null +++ b/lib/gitlab/client.rb @@ -0,0 +1,38 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +require 'graphql/client' + +class GitLab + class Client + + delegate_missing_to :client + + attr_reader :endpoint + + def initialize(endpoint, api_token, schema: nil) + @endpoint = endpoint + @api_token = api_token + schema(schema) if schema.present? + end + + def schema(source = http_client) + @schema ||= ::GraphQL::Client.load_schema(source) + end + + private + + def http_client + @http_client ||= GitLab::HttpClient.new(@endpoint, @api_token) + end + + def client + @client ||= begin + GraphQL::Client.new( + schema: schema, + execute: http_client, + ).tap do |client| + client.allow_dynamic_queries = true + end + end + end + end +end diff --git a/lib/gitlab/http_client.rb b/lib/gitlab/http_client.rb new file mode 100644 index 000000000..8a6fc6825 --- /dev/null +++ b/lib/gitlab/http_client.rb @@ -0,0 +1,23 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +require 'graphql/client' +require 'graphql/client/http' + +class GitLab + class HttpClient < ::GraphQL::Client::HTTP + + def initialize(endpoint, api_token) + raise 'api_token required' if api_token.blank? + raise 'endpoint required' if endpoint.blank? + + @api_token = api_token + + super(endpoint) + end + + def headers(_context) + { + "PRIVATE-TOKEN": @api_token + } + end + end +end diff --git a/lib/gitlab/linked_issue.rb b/lib/gitlab/linked_issue.rb new file mode 100644 index 000000000..27a618a25 --- /dev/null +++ b/lib/gitlab/linked_issue.rb @@ -0,0 +1,115 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +class GitLab + class LinkedIssue + + STATES_MAPPING = { + 'opened' => 'open' + }.freeze + + QUERY = <<-'GRAPHQL'.freeze + query($fullpath: ID!, $issue_id: String) { + project(fullPath: $fullpath) { + issue(iid: $issue_id) { + iid + title + state + milestone { + title + } + assignees { + edges { + node { + name + } + } + } + labels { + edges { + node { + title + color + textColor + description + } + } + } + } + } + } + GRAPHQL + + attr_reader :client + + def initialize(client) + @client = client + end + + def find_by(url) + @result = query_by_url(url) + return if @result.blank? + + to_h.merge(url: url) + end + + private + + def to_h + { + id: @result['iid'], + title: @result['title'], + icon_state: STATES_MAPPING.fetch(@result['state'], @result['state']), + milestone: milestone, + assignees: assignees, + labels: labels, + } + end + + def assignees + @result['assignees']['edges'].map do |assignee| + assignee['node']['name'] + end + end + + def labels + @result['labels']['edges'].map do |label| + { + text_color: label['node']['textColor'], + color: label['node']['color'], + title: label['node']['title'] + } + end + end + + def milestone + @result['milestone']['title'] + end + + def query + @query ||= client.parse GitLab::LinkedIssue::QUERY + end + + def query_by_url(url) + variables = variables(url) + return if variables.blank? + + response = client.query(query, variables: variables) + + response&.data&.project&.issue&.to_h&.deep_dup + end + + def variables(url) + return if url !~ %r{^https://([^/]+)/(.*)/-/issues/(\d+)$} + + host = $1 + fullpath = $2 + id = $3 + + return if client.endpoint.exclude?(host) + + { + fullpath: fullpath, + issue_id: id + } + end + end +end diff --git a/lib/omniauth/gitlab_database.rb b/lib/omniauth/gitlab_database.rb index 799f06e90..f9c04d6f7 100644 --- a/lib/omniauth/gitlab_database.rb +++ b/lib/omniauth/gitlab_database.rb @@ -1,4 +1,4 @@ -class GitlabDatabase < OmniAuth::Strategies::GitLab +class GitLabDatabase < OmniAuth::Strategies::GitLab option :name, 'gitlab' def initialize(app, *args, &block) diff --git a/public/assets/images/icons.svg b/public/assets/images/icons.svg index 1f6d347f3..0dd6507ae 100644 --- a/public/assets/images/icons.svg +++ b/public/assets/images/icons.svg @@ -324,6 +324,11 @@ + google-button diff --git a/public/assets/images/icons/gitlab-logo.svg b/public/assets/images/icons/gitlab-logo.svg new file mode 100644 index 000000000..d2afbf2e2 --- /dev/null +++ b/public/assets/images/icons/gitlab-logo.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <title>gitlab-logo + + \ No newline at end of file diff --git a/spec/integration/gitlab_spec.rb b/spec/integration/gitlab_spec.rb new file mode 100644 index 000000000..6c598cb78 --- /dev/null +++ b/spec/integration/gitlab_spec.rb @@ -0,0 +1,95 @@ +require 'rails_helper' +RSpec.describe GitLab, type: :integration do # rubocop:disable RSpec/FilePath + + before(:all) do # rubocop:disable RSpec/BeforeAfterAll + required_envs = %w[GITLAB_ENDPOINT GITLAB_APITOKEN] + required_envs.each do |key| + skip("NOTICE: Missing environment variable #{key} for test! (Please fill up: #{required_envs.join(' && ')})") if ENV[key].blank? + end + + # request schema only once for performance reasons + @cached_schema = described_class.new(ENV['GITLAB_ENDPOINT'], ENV['GITLAB_APITOKEN']).schema + end + + let(:instance) { described_class.new(ENV['GITLAB_ENDPOINT'], ENV['GITLAB_APITOKEN'], schema: schema) } + let(:schema) { @cached_schema } # rubocop:disable RSpec/InstanceVariable + let(:issue_data) do + { + id: '1', + title: 'Example issue', + url: ENV['GITLAB_ISSUE_LINK'], + icon_state: 'open', + milestone: 'important milestone', + assignees: ['zammad-robot'], + labels: [ + { + color: '#FF0000', + text_color: '#FFFFFF', + title: 'critical' + }, + { + color: '#0033CC', + text_color: '#FFFFFF', + title: 'label1' + }, + { + color: '#D1D100', + text_color: '#FFFFFF', + title: 'special' + } + ], + } + end + let(:invalid_issue_url) { 'https://git.example.com/group/project/-/issues/1' } + + describe '#schema' do + it 'returns GraphQL schema' do + expect(instance.schema).to respond_to(:to_graphql) + end + end + + describe '#issues_by_urls' do + let(:result) { instance.issues_by_urls([ issue_url ]) } + + context 'when issue exists' do + let(:issue_url) { ENV['GITLAB_ISSUE_LINK'] } + + it 'returns a result list' do + expect(result.size).to eq(1) + end + + it 'returns issue data in the result list' do + expect(result[0]).to eq(issue_data) + end + end + + context 'when issue does not exists' do + let(:issue_url) { invalid_issue_url } + + it 'returns no result' do + expect(result.size).to eq(0) + end + end + end + + describe '#issue_by_url' do + + let(:result) { instance.issue_by_url(issue_url) } + + context 'when issue exists' do + let(:issue_url) { ENV['GITLAB_ISSUE_LINK'] } + + it 'returns issue data' do + expect(result).to eq(issue_data) + end + end + + context 'when issue does not exists' do + let(:issue_url) { invalid_issue_url } + + it 'returns nil' do + expect(result).to be_nil + end + end + end +end diff --git a/spec/lib/core_ext/active_record/calculations/pluck_as_hash_spec.rb b/spec/lib/core_ext/active_record/calculations/pluck_as_hash_spec.rb index d775e68ae..0bb26e3d0 100644 --- a/spec/lib/core_ext/active_record/calculations/pluck_as_hash_spec.rb +++ b/spec/lib/core_ext/active_record/calculations/pluck_as_hash_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require 'rails_helper' RSpec.describe ActiveRecord::Calculations do # rubocop:disable RSpec/FilePath diff --git a/spec/requests/integration/gitlab_spec.rb b/spec/requests/integration/gitlab_spec.rb new file mode 100644 index 000000000..914e179a6 --- /dev/null +++ b/spec/requests/integration/gitlab_spec.rb @@ -0,0 +1,115 @@ +require 'rails_helper' + +# rubocop:disable RSpec/StubbedMock,RSpec/MessageSpies + +RSpec.describe 'GitLab', type: :request do + + let(:token) { 't0k3N' } + let(:endpoint) { 'https://git.example.com/api/graphql' } + + let!(:admin) do + create(:admin, groups: Group.all) + end + + let!(:agent) do + create(:agent, groups: Group.all) + end + + let(:issue_data) do + { + id: '1', + title: 'Example issue', + url: ENV['GITLAB_ISSUE_LINK'], + icon_state: 'open', + milestone: 'important milestone', + assignees: ['zammad-robot'], + labels: [ + { + color: '#FF0000', + text_color: '#FFFFFF', + title: 'critical' + }, + { + color: '#0033CC', + text_color: '#FFFFFF', + title: 'label1' + }, + { + color: '#D1D100', + text_color: '#FFFFFF', + title: 'special' + } + ], + } + end + + let(:dummy_schema) do + { + a: :b + } + end + + describe 'request handling' do + it 'does verify integration' do + params = { + endpoint: endpoint, + api_token: token, + } + authenticated_as(agent) + post '/api/v1/integration/gitlab/verify', params: params, as: :json + expect(response).to have_http_status(:forbidden) + expect(json_response).to be_a_kind_of(Hash) + expect(json_response).not_to be_blank + expect(json_response['error']).to eq('Not authorized (user)!') + + authenticated_as(admin) + instance = instance_double('GitLab') + expect(GitLab).to receive(:new).with(endpoint, token).and_return instance + expect(instance).to receive(:schema).and_return(dummy_schema) + + post '/api/v1/integration/gitlab/verify', params: params, as: :json + expect(response).to have_http_status(:ok) + expect(json_response).to be_a_kind_of(Hash) + expect(json_response).not_to be_blank + expect(json_response['result']).to eq('ok') + expect(json_response['response']).to eq(dummy_schema.to_json) + end + + it 'does query objects' do + params = { + links: [ ENV['GITLAB_ISSUE_LINK'] ], + } + authenticated_as(agent) + instance = instance_double('GitLab') + expect(GitLab).to receive(:new).and_return instance + expect(instance).to receive(:issues_by_urls).and_return([issue_data]) + + post '/api/v1/integration/gitlab', params: params, as: :json + expect(response).to have_http_status(:ok) + + expect(json_response).to be_a_kind_of(Hash) + expect(json_response).not_to be_blank + expect(json_response['result']).to eq('ok') + expect(json_response['response']).to eq([issue_data.deep_stringify_keys]) + end + + it 'does save ticket issues' do + ticket = create(:ticket, group: Group.first) + + params = { + ticket_id: ticket.id, + issue_links: [ ENV['GITLAB_ISSUE_LINK'] ], + } + authenticated_as(agent) + post '/api/v1/integration/gitlab_ticket_update', params: params, as: :json + expect(response).to have_http_status(:ok) + expect(json_response).to be_a_kind_of(Hash) + expect(json_response).not_to be_blank + expect(json_response['result']).to eq('ok') + + expect(ticket.reload.preferences[:gitlab][:issue_links]).to eq(params[:issue_links]) + end + end +end + +# rubocop:enable RSpec/StubbedMock,RSpec/MessageSpies diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb index f71d62542..88b75201f 100644 --- a/spec/support/vcr.rb +++ b/spec/support/vcr.rb @@ -1,4 +1,4 @@ -VCR_IGNORE_MATCHING_HOSTS = %w[zammad.com google.com elasticsearch selenium login.microsoftonline.com zammad.org].freeze +VCR_IGNORE_MATCHING_HOSTS = %w[elasticsearch selenium zammad.org zammad.com znuny.com google.com login.microsoftonline.com].freeze VCR_IGNORE_MATCHING_REGEXPS = [/^192\.168\.\d+\.\d+$/].freeze VCR.configure do |config| diff --git a/spec/system/ticket/create_spec.rb b/spec/system/ticket/create_spec.rb index b6bb0857a..31a7787d9 100644 --- a/spec/system/ticket/create_spec.rb +++ b/spec/system/ticket/create_spec.rb @@ -341,4 +341,62 @@ RSpec.describe 'Ticket Create', type: :system do end end end + + describe 'GitLab Integration', :integration, authenticated_as: :authenticate do + let(:customer) { create(:customer) } + let(:agent) { create(:agent, groups: [Group.find_by(name: 'Users')]) } + let!(:template) { create(:template, :dummy_data, group: Group.find_by(name: 'Users'), owner: agent, customer: customer) } + + before(:all) do # rubocop:disable RSpec/BeforeAfterAll + required_envs = %w[GITLAB_ENDPOINT GITLAB_APITOKEN] + required_envs.each do |key| + skip("NOTICE: Missing environment variable #{key} for test! (Please fill up: #{required_envs.join(' && ')})") if ENV[key].blank? + end + + # request schema only once for performance reasons + @cached_schema = GitLab.new(ENV['GITLAB_ENDPOINT'], ENV['GITLAB_APITOKEN']).schema.to_json + end + + def authenticate + Setting.set('gitlab_integration', true) + Setting.set('gitlab_config', { + api_token: ENV['GITLAB_APITOKEN'], + endpoint: ENV['GITLAB_ENDPOINT'], + schema: @cached_schema, # rubocop:disable RSpec/InstanceVariable + }) + true + end + + it 'creates a ticket with links' do + visit 'ticket/create' + within(:active_content) do + use_template(template) + + # switch to gitlab sidebar + click('.tabsSidebar-tab[data-tab=gitlab]') + click('.sidebar-header-headline.js-headline') + + # add issue + click_on 'Link issue' + fill_in 'link', with: ENV['GITLAB_ISSUE_LINK'] + click_on 'Submit' + await_empty_ajax_queue + + # verify issue + content = find('.sidebar-git-issue-content') + expect(content).to have_text('#1 Example issue') + expect(content).to have_text('critical') + expect(content).to have_text('special') + expect(content).to have_text('important milestone') + expect(content).to have_text('zammad-robot') + + # create Ticket + click '.js-submit' + await_empty_ajax_queue + + # check stored data + expect(Ticket.last.preferences[:gitlab][:issue_links][0]).to eq(ENV['GITLAB_ISSUE_LINK']) + end + end + end end diff --git a/spec/system/ticket/zoom_spec.rb b/spec/system/ticket/zoom_spec.rb index ca23f3ec7..4c801477a 100644 --- a/spec/system/ticket/zoom_spec.rb +++ b/spec/system/ticket/zoom_spec.rb @@ -1474,4 +1474,68 @@ RSpec.describe 'Ticket zoom', type: :system do end end end + + describe 'GitLab Integration', :integration, authenticated_as: :authenticate do + let!(:ticket) { create(:ticket, group: Group.find_by(name: 'Users')) } + + before(:all) do # rubocop:disable RSpec/BeforeAfterAll + required_envs = %w[GITLAB_ENDPOINT GITLAB_APITOKEN] + required_envs.each do |key| + skip("NOTICE: Missing environment variable #{key} for test! (Please fill up: #{required_envs.join(' && ')})") if ENV[key].blank? + end + + # request schema only once for performance reasons + @cached_schema = GitLab.new(ENV['GITLAB_ENDPOINT'], ENV['GITLAB_APITOKEN']).schema.to_json + end + + def authenticate + Setting.set('gitlab_integration', true) + Setting.set('gitlab_config', { + api_token: ENV['GITLAB_APITOKEN'], + endpoint: ENV['GITLAB_ENDPOINT'], + schema: @cached_schema, # rubocop:disable RSpec/InstanceVariable + }) + true + end + + it 'creates links and removes them' do + visit "#ticket/zoom/#{ticket.id}" + within(:active_content) do + + # switch to GitLab sidebar + click('.tabsSidebar-tab[data-tab=gitlab]') + click('.sidebar-header-headline.js-headline') + + # add issue + click_on 'Link issue' + fill_in 'link', with: ENV['GITLAB_ISSUE_LINK'] + click_on 'Submit' + await_empty_ajax_queue + + # verify issue + content = find('.sidebar-git-issue-content') + expect(content).to have_text('#1 Example issue') + expect(content).to have_text('critical') + expect(content).to have_text('special') + expect(content).to have_text('important milestone') + expect(content).to have_text('zammad-robot') + + expect(ticket.reload.preferences[:gitlab][:issue_links][0]).to eq(ENV['GITLAB_ISSUE_LINK']) + + # check sidebar counter increased to 1 + expect(find('.tabsSidebar-tab[data-tab=gitlab] .js-tabCounter')).to have_text('1') + + # delete issue + click(".sidebar-git-issue-delete span[data-issue-id='#{ENV['GITLAB_ISSUE_LINK']}']") + await_empty_ajax_queue + + content = find('.sidebar[data-tab=gitlab] .sidebar-content') + expect(content).to have_text('No linked issues') + expect(ticket.reload.preferences[:gitlab][:issue_links][0]).to be nil + + # check that counter got removed + expect(page).to have_no_selector('.tabsSidebar-tab[data-tab=gitlab] .js-tabCounter') + end + end + end end