Fixes #435 - GitLab integration.

This commit is contained in:
Rolf Schmidt 2021-03-10 16:11:14 +00:00 committed by Thorsten Eckel
parent 20d3c5027f
commit 97c9d541e9
42 changed files with 1116 additions and 20 deletions

View file

@ -70,7 +70,7 @@ include:
RAILS_ENV: "test" RAILS_ENV: "test"
script: script:
- bundle exec rake zammad:ci:test:prepare - 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 # we need at least one job to store and include this template
# $IGNORE is not defined # $IGNORE is not defined

View file

@ -1,12 +1,29 @@
include: include:
# browser-integration # 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_chrome.yml'
- local: '/.gitlab/ci/browser-integration/facebook_ff.yml' - local: '/.gitlab/ci/browser-integration/facebook_ff.yml'
- local: '/.gitlab/ci/browser-integration/idoit_chrome.yml' - local: '/.gitlab/ci/browser-integration/idoit_chrome.yml'
- local: '/.gitlab/ci/browser-integration/otrs_chrome.yml' - local: '/.gitlab/ci/browser-integration/otrs_chrome.yml'
- local: '/.gitlab/ci/browser-integration/zendesk_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 .template_browser-integration: &template_browser-integration
stage: browser-integration stage: browser-integration
dependencies: dependencies:

View file

@ -0,0 +1,5 @@
integration_capybara_chrome:
extends:
- .template_browser-integration_capybara
variables:
BROWSER: "chrome"

View file

@ -0,0 +1,5 @@
integration_capybara_ff:
extends:
- .template_browser-integration_capybara
variables:
BROWSER: "firefox"

View file

@ -274,6 +274,7 @@ RSpec/ExampleLength:
- 'spec/requests/integration/check_mk_spec.rb' - 'spec/requests/integration/check_mk_spec.rb'
- 'spec/requests/integration/cti_spec.rb' - 'spec/requests/integration/cti_spec.rb'
- 'spec/requests/integration/idoit_spec.rb' - 'spec/requests/integration/idoit_spec.rb'
- 'spec/requests/integration/gitlab_spec.rb'
- 'spec/requests/integration/monitoring_spec.rb' - 'spec/requests/integration/monitoring_spec.rb'
- 'spec/requests/integration/object_manager_attributes_spec.rb' - 'spec/requests/integration/object_manager_attributes_spec.rb'
- 'spec/requests/integration/placetel_spec.rb' - 'spec/requests/integration/placetel_spec.rb'
@ -554,6 +555,7 @@ RSpec/MultipleExpectations:
- 'spec/requests/integration/check_mk_spec.rb' - 'spec/requests/integration/check_mk_spec.rb'
- 'spec/requests/integration/cti_spec.rb' - 'spec/requests/integration/cti_spec.rb'
- 'spec/requests/integration/idoit_spec.rb' - 'spec/requests/integration/idoit_spec.rb'
- 'spec/requests/integration/gitlab_spec.rb'
- 'spec/requests/integration/monitoring_spec.rb' - 'spec/requests/integration/monitoring_spec.rb'
- 'spec/requests/integration/object_manager_attributes_spec.rb' - 'spec/requests/integration/object_manager_attributes_spec.rb'
- 'spec/requests/integration/placetel_spec.rb' - 'spec/requests/integration/placetel_spec.rb'

View file

@ -124,6 +124,7 @@ gem 'acts_as_list'
# integrations # integrations
gem 'clearbit' gem 'clearbit'
gem 'graphql-client'
gem 'net-ldap' gem 'net-ldap'
gem 'slack-notifier' gem 'slack-notifier'
gem 'zendesk_api' gem 'zendesk_api'

View file

@ -218,6 +218,10 @@ GEM
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
gmail_xoauth (0.4.2) gmail_xoauth (0.4.2)
oauth (>= 0.3.6) oauth (>= 0.3.6)
graphql (1.11.6)
graphql-client (0.16.0)
activesupport (>= 3.0)
graphql (~> 1.8)
guard (2.15.0) guard (2.15.0)
formatador (>= 0.2.4) formatador (>= 0.2.4)
listen (>= 2.7, < 4.0) listen (>= 2.7, < 4.0)
@ -613,6 +617,7 @@ DEPENDENCIES
faker faker
github_changelog_generator github_changelog_generator
gmail_xoauth gmail_xoauth
graphql-client
guard guard
guard-livereload guard-livereload
guard-symlink guard-symlink

View file

@ -225,7 +225,7 @@
"license": "" "license": ""
}, },
"gitlab-button.svg": { "gitlab-button.svg": {
"author": "Gitlab", "author": "GitLab",
"url": "", "url": "",
"license": "" "license": ""
}, },
@ -694,4 +694,4 @@
"url": "https://thenounproject.com/term/key/1247931/", "url": "https://thenounproject.com/term/key/1247931/",
"license": "CC 3.0 Attribution" "license": "CC 3.0 Attribution"
} }
} }

View file

@ -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'
)

View file

@ -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, @)

View file

@ -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("<div>#{App.i18n.translateInline('No linked issues')}</div>")
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
)
)

View file

@ -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')

View file

@ -122,6 +122,12 @@ class App.Sidebar extends App.Controller
# remember current tab # remember current tab
@currentTab = name @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 # show sidebar if not shown
@showSidebar() @showSidebar()

View file

@ -1,4 +1,4 @@
<% if @counterPossible is true: %> <% if @counterPossible is true and @counter and @counter > 0: %>
<div class="tabsSidebar-tab-count js-tabCounter <% if !@counter || @counter is 0: %>hide<% end %><% if @cssClass: %><%= @cssClass %><% end %>"><%= @counter %></div> <div class="tabsSidebar-tab-count js-tabCounter <% if @cssClass: %><%= @cssClass %><% end %>"><%= @counter %></div>
<% end %> <% end %>
<%- @Icon(@icon) %> <%- @Icon(@icon) %>

View file

@ -0,0 +1,3 @@
<form class="flex horizontal js-result">
<input type="text" name="link" value="" autocomplete="off" placeholder="<%= @placeholder %>" class="form-control"/>
</form>

View file

@ -0,0 +1,22 @@
<form>
<h2><%- @T('Settings') %></h2>
<div class="settings-entry">
<table class="settings-list" style="width: 100%;">
<thead>
<tr>
<th width="20%"><%- @T('Name') %>
<th width="80%"><%- @T('Value') %>
</thead>
<tbody>
<tr>
<td class="settings-list-row-control"><%- @T('Endpoint') %> *
<td class="settings-list-control-cell"><input type="text" class="form-control form-control--small" value="<%= @config.endpoint %>" placeholder="https://git.example.com/api/graphql" name="endpoint">
<tr>
<td class="settings-list-row-control"><%- @T('API token') %> *
<td class="settings-list-control-cell"><input type="text" class="form-control form-control--small" value="<%= @config.api_token %>" name="api_token">
</tbody>
</table>
</div>
<button type="submit" class="btn btn--primary js-submit"><%- @T('Save') %></button>
</form>

View file

@ -8,12 +8,12 @@
<th width="80%"><%- @T('Value') %> <th width="80%"><%- @T('Value') %>
</thead> </thead>
<tbody> <tbody>
<tr>
<td class="settings-list-row-control"><%- @T('API token') %> *
<td class="settings-list-control-cell"><input type="text" class="form-control form-control--small" value="<%= @config.api_token %>" name="api_token">
<tr> <tr>
<td class="settings-list-row-control"><%- @T('Endpoint') %> * <td class="settings-list-row-control"><%- @T('Endpoint') %> *
<td class="settings-list-control-cell"><input type="text" class="form-control form-control--small" value="<%= @config.endpoint %>" placeholder="https://idoit.example.com/i-doit/" name="endpoint"> <td class="settings-list-control-cell"><input type="text" class="form-control form-control--small" value="<%= @config.endpoint %>" placeholder="https://idoit.example.com/i-doit/" name="endpoint">
<tr>
<td class="settings-list-row-control"><%- @T('API token') %> *
<td class="settings-list-control-cell"><input type="text" class="form-control form-control--small" value="<%= @config.api_token %>" name="api_token">
<tr> <tr>
<td class="settings-list-row-control"><%- @T('Client ID') %> <td class="settings-list-row-control"><%- @T('Client ID') %>
<td class="settings-list-control-cell"><input type="text" class="form-control form-control--small" value="<%= @config.client_id %>" name="client_id"> <td class="settings-list-control-cell"><input type="text" class="form-control form-control--small" value="<%= @config.client_id %>" name="client_id">
@ -22,4 +22,4 @@
</div> </div>
<button type="submit" class="btn btn--primary js-submit"><%- @T('Save') %></button> <button type="submit" class="btn btn--primary js-submit"><%- @T('Save') %></button>
</form> </form>

View file

@ -0,0 +1,36 @@
<% for issue in @issues: %>
<div class="sidebar-git-issue-delete">
<span class="list-item-delete js-delete" data-issue-id="<%= issue.url %>" data-type="remove">
<%- @Icon('diagonal-cross') %>
</span>
</div>
<div class="sidebar-git-issue-content">
<div class="sidebar-block">
<a href="<%- issue.url %>" target="_blank">
<span class="icon-holder" title="<%= @T(issue.icon_state) %>">
<%- @Icon('task-state', issue.icon_state) %>
</span>
#<%= issue.id %> <%= issue.title %>
</a>
</div>
<% if issue.milestone: %>
<div class="sidebar-block">
<label><%- @T('Milestone') %></label>
<%= @T(issue.milestone) %>
</div>
<% end %>
<% if issue.assignees.length > 0: %>
<div class="sidebar-block">
<label><%- @T('Assignee') %></label>
<%= issue.assignees.join(', ') %>
</div>
<% end %>
<% if issue.labels.length > 0: %>
<div class="sidebar-block">
<label><%- @T('Labels') %></label>
<% for label in issue.labels: %><span class="badge" style="color: <%- label.text_color %>; background-color: <%- label.color %>"><%= label.title %></span> <% end %>
</div>
<% end %>
</div>
<hr class="clearfix">
<% end %>

View file

@ -44,6 +44,7 @@
.icon-full-logo { width: 175px; height: 50px; } .icon-full-logo { width: 175px; height: 50px; }
.icon-github-button { width: 29px; height: 24px; } .icon-github-button { width: 29px; height: 24px; }
.icon-gitlab-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-google-button { width: 29px; height: 24px; }
.icon-group { width: 24px; height: 24px; } .icon-group { width: 24px; height: 24px; }
.icon-help { width: 16px; height: 16px; } .icon-help { width: 16px; height: 16px; }

View file

@ -5108,6 +5108,15 @@ footer {
} }
} }
.sidebar-git-issue-delete {
text-align: right;
float: right;
}
.sidebar-git-issue-content {
width: 90%;
}
.main + .sidebar { .main + .sidebar {
border-right: none; border-right: none;
border-left: 1px solid #e6e6e6; border-left: 1px solid #e6e6e6;

View file

@ -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

View file

@ -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

View file

@ -21,4 +21,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
# see: KnowledgeBase.table_name.singularize # see: KnowledgeBase.table_name.singularize
inflect.singular(/(knowledge_base)s$/i, '\1') inflect.singular(/(knowledge_base)s$/i, '\1')
inflect.acronym 'SMIME' inflect.acronym 'SMIME'
inflect.acronym 'GitLab'
end end

View file

@ -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' provider :github_database, 'not_change_will_be_set_by_database', 'not_change_will_be_set_by_database'
# gitlab database connect # 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: { client_options: {
site: 'https://not_change_will_be_set_by_database', site: 'https://not_change_will_be_set_by_database',
authorize_url: '/oauth/authorize', authorize_url: '/oauth/authorize',

View file

@ -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

Binary file not shown.

View file

@ -1,4 +1,4 @@
class Issue2595GitlabPlaceholder < ActiveRecord::Migration[5.2] class Issue2595GitLabPlaceholder < ActiveRecord::Migration[5.2]
def change def change
# return if it's a new setup # return if it's a new setup
return if !Setting.exists?(name: 'system_init_done') return if !Setting.exists?(name: 'system_init_done')

View file

@ -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

View file

@ -1427,18 +1427,18 @@ Setting.create_if_not_exists(
preferences: { preferences: {
controller: 'SettingsAreaSwitch', controller: 'SettingsAreaSwitch',
sub: ['auth_gitlab_credentials'], sub: ['auth_gitlab_credentials'],
title_i18n: ['Gitlab'], title_i18n: ['GitLab'],
description_i18n: ['Gitlab', 'Gitlab Applications', 'https://your-gitlab-host/admin/applications'], description_i18n: ['GitLab', 'GitLab Applications', 'https://your-gitlab-host/admin/applications'],
permission: ['admin.security'], permission: ['admin.security'],
}, },
state: false, state: false,
frontend: true frontend: true
) )
Setting.create_if_not_exists( Setting.create_if_not_exists(
title: 'Gitlab App Credentials', title: 'GitLab App Credentials',
name: 'auth_gitlab_credentials', name: 'auth_gitlab_credentials',
area: 'Security::ThirdPartyAuthentication::Gitlab', area: 'Security::ThirdPartyAuthentication::GitLab',
description: 'Enables user authentication via Gitlab.', description: 'Enables user authentication via GitLab.',
options: { options: {
form: [ form: [
{ {
@ -4046,6 +4046,48 @@ Setting.create_if_not_exists(
}, },
frontend: false, 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( Setting.create_if_not_exists(
title: 'Defines sync transaction backend.', title: 'Defines sync transaction backend.',
name: '0100_trigger', name: '0100_trigger',

27
lib/gitlab.rb Normal file
View file

@ -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

38
lib/gitlab/client.rb Normal file
View file

@ -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

23
lib/gitlab/http_client.rb Normal file
View file

@ -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

115
lib/gitlab/linked_issue.rb Normal file
View file

@ -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

View file

@ -1,4 +1,4 @@
class GitlabDatabase < OmniAuth::Strategies::GitLab class GitLabDatabase < OmniAuth::Strategies::GitLab
option :name, 'gitlab' option :name, 'gitlab'
def initialize(app, *args, &block) def initialize(app, *args, &block)

View file

@ -324,6 +324,11 @@
<path d="M24.906 9.481l1.333 4.101a.908.908 0 0 1-.33 1.015l-11.533 8.38L24.906 9.48z" fill="#FF998A"/> <path d="M24.906 9.481l1.333 4.101a.908.908 0 0 1-.33 1.015l-11.533 8.38L24.906 9.48z" fill="#FF998A"/>
<path d="M25.066 10.022H17.99l3.41-8.668a.454.454 0 0 1 .864 0l2.8 8.668z" fill="#FFF"/> <path d="M25.066 10.022H17.99l3.41-8.668a.454.454 0 0 1 .864 0l2.8 8.668z" fill="#FFF"/>
</g> </g>
</symbol><symbol id="icon-gitlab-logo" viewBox="0 0 24 24">
<title>
gitlab-logo
</title>
<path d="M23.955 13.64l-1.343-4.133-2.661-8.19a.457.457 0 0 0-.87 0l-2.876 8.726H7.599l-2.68-8.727a.457.457 0 0 0-.87 0L1.388 9.507.045 13.64a.915.915 0 0 0 .332 1.023L12 23.108l11.623-8.445a.915.915 0 0 0 .332-1.023" fill-rule="evenodd"/>
</symbol><symbol id="icon-google-button" viewBox="0 0 29 24"> </symbol><symbol id="icon-google-button" viewBox="0 0 29 24">
<title> <title>
google-button google-button

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View file

@ -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</title>
<g id="gitlab-logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M23.9551616,10.4673469 L22.6122253,14.6003733 L19.9507177,22.7917715 C19.8138186,23.2131893 19.2175573,23.2131893 19.0805932,22.7917715 L16.2048292,14.0650133 L7.59876258,14.0650133 L4.91936909,22.7917715 C4.78247003,23.2131893 4.18620865,23.2131893 4.04924461,22.7917715 L1.38773702,14.6003733 L0.0448656821,10.4673469 C-0.0776742518,10.0903709 0.0565609036,9.67739968 0.377204898,9.44440484 L11.9999811,1 L23.6228224,9.44440484 C23.9434664,9.67739968 24.0776366,10.0903709 23.9551616,10.4673469" fill="#50E3C2" transform="translate(12.000000, 12.053917) scale(1, -1) translate(-12.000000, -12.053917) "></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 948 B

View file

@ -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

View file

@ -1,5 +1,3 @@
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
RSpec.describe ActiveRecord::Calculations do # rubocop:disable RSpec/FilePath RSpec.describe ActiveRecord::Calculations do # rubocop:disable RSpec/FilePath

View file

@ -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

View file

@ -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_IGNORE_MATCHING_REGEXPS = [/^192\.168\.\d+\.\d+$/].freeze
VCR.configure do |config| VCR.configure do |config|

View file

@ -341,4 +341,62 @@ RSpec.describe 'Ticket Create', type: :system do
end end
end 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 end

View file

@ -1474,4 +1474,68 @@ RSpec.describe 'Ticket zoom', type: :system do
end end
end 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 end