diff --git a/.rubocop/todo.rspec.yml b/.rubocop/todo.rspec.yml index f8448ee64..0e105c1fc 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/github_spec.rb' - 'spec/requests/integration/gitlab_spec.rb' - 'spec/requests/integration/monitoring_spec.rb' - 'spec/requests/integration/object_manager_attributes_spec.rb' @@ -555,6 +556,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/github_spec.rb' - 'spec/requests/integration/gitlab_spec.rb' - 'spec/requests/integration/monitoring_spec.rb' - 'spec/requests/integration/object_manager_attributes_spec.rb' diff --git a/LICENSE-ICONS-3RD-PARTY.json b/LICENSE-ICONS-3RD-PARTY.json index 56779c6db..bfe559355 100644 --- a/LICENSE-ICONS-3RD-PARTY.json +++ b/LICENSE-ICONS-3RD-PARTY.json @@ -224,6 +224,11 @@ "url": "", "license": "" }, + "github-logo.svg": { + "author": "Github", + "url": "", + "license": "" + }, "gitlab-button.svg": { "author": "GitLab", "url": "", @@ -579,6 +584,11 @@ "url": "", "license": "MIT" }, + "sso-button.svg": { + "author": "Tanu Doank", + "url": "https:\/\/thenounproject.com\/term\/key\/1247931\/", + "license": "CC 3.0 Attribution" + }, "status-modified-outer-circle.svg": { "author": "Zammad", "url": "", @@ -688,10 +698,5 @@ "author": "Felix Niklas", "url": "", "license": "MIT" - }, - "sso-button.svg": { - "author": "Tanu Doank", - "url": "https://thenounproject.com/term/key/1247931/", - "license": "CC 3.0 Attribution" } } diff --git a/app/assets/javascripts/app/controllers/_integration/github.coffee b/app/assets/javascripts/app/controllers/_integration/github.coffee new file mode 100644 index 000000000..e3ace7e5a --- /dev/null +++ b/app/assets/javascripts/app/controllers/_integration/github.coffee @@ -0,0 +1,82 @@ +class GitHub extends App.ControllerIntegrationBase + featureIntegration: 'github_integration' + featureName: 'GitHub' + featureConfig: 'github_config' + description: [ + ['This service allows you to connect %s with %s.', 'GitHub', '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('github_config') + + @html App.view('integration/github')( + config: config + ) + + update: (e) => + e.preventDefault() + config = @formParam(e.target) + @validateAndSave(config) + + validateAndSave: (config) => + App.Ajax.request( + id: 'github' + type: 'POST' + url: "#{@apiPath}/integration/github/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('github_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('github_integration') + +App.Config.set( + 'IntegrationGitHub' + { + name: 'GitHub' + target: '#system/integration/github' + description: 'Link GitHub issues to your tickets.' + controller: GitHub + state: State + } + 'NavBarIntegrations' +) diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_github.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_github.coffee new file mode 100644 index 000000000..41d4a70c8 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_github.coffee @@ -0,0 +1,6 @@ +class SidebarGitHub extends App.SidebarGitIssue + provider: 'GitHub' + urlPlaceholder: 'https://github.com/organization/repository/issues/42' + +App.Config.set('500-GitHub', SidebarGitHub, 'TicketCreateSidebar') +App.Config.set('500-GitHub', SidebarGitHub, 'TicketZoomSidebar') diff --git a/app/assets/javascripts/app/views/integration/github.jst.eco b/app/assets/javascripts/app/views/integration/github.jst.eco new file mode 100644 index 000000000..2239c3654 --- /dev/null +++ b/app/assets/javascripts/app/views/integration/github.jst.eco @@ -0,0 +1,22 @@ +
diff --git a/app/assets/stylesheets/svg-dimensions.css b/app/assets/stylesheets/svg-dimensions.css index 0bebf8c11..a2b6a1dc2 100644 --- a/app/assets/stylesheets/svg-dimensions.css +++ b/app/assets/stylesheets/svg-dimensions.css @@ -43,6 +43,7 @@ .icon-forward { width: 16px; height: 17px; } .icon-full-logo { width: 175px; height: 50px; } .icon-github-button { width: 29px; height: 24px; } +.icon-github-logo { width: 24px; height: 24px; } .icon-gitlab-button { width: 29px; height: 24px; } .icon-gitlab-logo { width: 24px; height: 24px; } .icon-google-button { width: 29px; height: 24px; } diff --git a/app/controllers/integration/github_controller.rb b/app/controllers/integration/github_controller.rb new file mode 100644 index 000000000..2cab8e130 --- /dev/null +++ b/app/controllers/integration/github_controller.rb @@ -0,0 +1,53 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class Integration::GitHubController < ApplicationController + prepend_before_action { authentication_check && authorize! } + + def verify + github = ::GitHub.new(params[:endpoint], params[:api_token]) + render json: { + result: 'ok', + response: github.schema.to_json, + } + rescue => e + logger.error e + + render json: { + result: 'failed', + message: e.message, + } + end + + def query + config = Setting.get('github_config') + + github = ::GitHub.new(config['endpoint'], config['api_token'], schema: config['schema']) + + render json: { + result: 'ok', + response: github.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[:github] ||= {} + ticket.preferences[:github][:issue_links] = Array(params[:issue_links]).uniq + ticket.save! + end + + render json: { + result: 'ok', + } + end + +end diff --git a/app/policies/controllers/integration/github_controller_policy.rb b/app/policies/controllers/integration/github_controller_policy.rb new file mode 100644 index 000000000..d0cebd74d --- /dev/null +++ b/app/policies/controllers/integration/github_controller_policy.rb @@ -0,0 +1,5 @@ +class Controllers::Integration::GitHubControllerPolicy < Controllers::ApplicationControllerPolicy + permit! %i[query update], to: 'ticket.agent' + permit! :verify, to: 'admin.integration.github' + default_permit!(['agent.integration.github', 'admin.integration.github']) +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 6987cc812..ab5925cb1 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -22,4 +22,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.singular(/(knowledge_base)s$/i, '\1') inflect.acronym 'SMIME' inflect.acronym 'GitLab' + inflect.acronym 'GitHub' end diff --git a/config/routes/integration_github.rb b/config/routes/integration_github.rb new file mode 100644 index 000000000..0775f869a --- /dev/null +++ b/config/routes/integration_github.rb @@ -0,0 +1,9 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/integration/github', to: 'integration/github#query', via: :post + match api_path + '/integration/github', to: 'integration/github#query', via: :get + match api_path + '/integration/github/verify', to: 'integration/github#verify', via: :post + match api_path + '/integration/github_ticket_update', to: 'integration/github#update', via: :post + +end diff --git a/db/migrate/20210308000001_github_support.rb b/db/migrate/20210308000001_github_support.rb new file mode 100644 index 000000000..ead086fb4 --- /dev/null +++ b/db/migrate/20210308000001_github_support.rb @@ -0,0 +1,51 @@ +class GitHubSupport < 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: 'GitHub integration', + name: 'github_integration', + area: 'Integration::Switch', + description: 'Defines if the GitHub (http://www.github.com) integration is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'github_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: 'GitHub config', + name: 'github_config', + area: 'Integration::GitHub', + description: 'Stores the GitHub configuration.', + options: {}, + state: { + endpoint: 'https://api.github.com/graphql', + }, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, + ) + end + +end diff --git a/db/seeds/settings.rb b/db/seeds/settings.rb index cd1670a1c..32de72086 100644 --- a/db/seeds/settings.rb +++ b/db/seeds/settings.rb @@ -4088,6 +4088,48 @@ Setting.create_if_not_exists( }, frontend: false, ) +Setting.create_if_not_exists( + title: 'GitHub integration', + name: 'github_integration', + area: 'Integration::Switch', + description: 'Defines if the GitHub (http://www.github.com) integration is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'github_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: 'GitHub config', + name: 'github_config', + area: 'Integration::GitHub', + description: 'Stores the GitHub configuration.', + options: {}, + state: { + endpoint: 'https://api.github.com/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/github.rb b/lib/github.rb new file mode 100644 index 000000000..de35f63c6 --- /dev/null +++ b/lib/github.rb @@ -0,0 +1,27 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class GitHub + extend Forwardable + + attr_reader :client + + def_delegator :client, :schema + + def initialize(*args, **kargs) + @client = GitHub::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 = GitHub::LinkedIssue.new(client) + issue.find_by(url)&.to_h + end +end diff --git a/lib/github/client.rb b/lib/github/client.rb new file mode 100644 index 000000000..adbcfde6d --- /dev/null +++ b/lib/github/client.rb @@ -0,0 +1,38 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +require 'graphql/client' + +class GitHub + 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 ||= GitHub::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/github/http_client.rb b/lib/github/http_client.rb new file mode 100644 index 000000000..e00c3da54 --- /dev/null +++ b/lib/github/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 GitHub + 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) + { + Authorization: "bearer #{@api_token}" + } + end + end +end diff --git a/lib/github/linked_issue.rb b/lib/github/linked_issue.rb new file mode 100644 index 000000000..1dd647c89 --- /dev/null +++ b/lib/github/linked_issue.rb @@ -0,0 +1,120 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +class GitHub + class LinkedIssue + + STATES_MAPPING = { + 'OPEN' => 'open', + 'CLOSED' => 'closed' + }.freeze + + QUERY = <<-'GRAPHQL'.freeze + query($repositor_owner: String!, $repository_name: String!, $issue_id: Int!) { + repository(owner: $repositor_owner, name: $repository_name) { + issue(number: $issue_id) { + number + title + state + milestone { + title + } + assignees(last: 100) { + edges { + node { + name + } + } + } + labels(last: 100) { + edges { + node { + name + color + } + } + } + } + } + } + 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['number'].to_s, + 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: text_color(label['node']['color']), + color: "##{label['node']['color']}", + title: label['node']['name'] + } + end + end + + def text_color(background_color) + background_color.to_i(16) > 0xFFF / 2 ? '#000000' : '#FFFFFF' + end + + def milestone + @result['milestone']['title'] + end + + def query + @query ||= client.parse GitHub::LinkedIssue::QUERY + end + + def query_by_url(url) + variables = variables(url) + return if variables.blank? + + response = client.query(query, variables: variables) + + response&.data&.repository&.issue&.to_h&.deep_dup + end + + def variables(url) + return if url !~ %r{^https://([^/]+)/([^/]+)/([^/]+)/issues/(\d+)$} + + host = $1 + repositor_owner = $2 + repository_name = $3 + id = $4 + + return if client.endpoint.exclude?(host) + + { + repositor_owner: repositor_owner, + repository_name: repository_name, + issue_id: id.to_i, + } + end + end +end diff --git a/public/assets/images/icons.svg b/public/assets/images/icons.svg index 0dd6507ae..922167867 100644 --- a/public/assets/images/icons.svg +++ b/public/assets/images/icons.svg @@ -310,6 +310,11 @@ github-button