Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
- forest_admin_datasource_mongoid
- forest_admin_rpc_agent
- forest_admin_datasource_rpc
- forest_admin_datasource_zendesk

steps:
- name: Checkout
Expand Down Expand Up @@ -66,6 +67,7 @@ jobs:
- forest_admin_datasource_mongoid
- forest_admin_rpc_agent
- forest_admin_datasource_rpc
- forest_admin_datasource_zendesk
services:
mongodb:
image: mongo:latest
Expand Down Expand Up @@ -129,7 +131,7 @@ jobs:
with:
verbose: true
oidc: true
files: ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_active_record/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_customizer/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_toolkit/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_rpc_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_rpc/coverage.json
files: ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_active_record/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_customizer/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_toolkit/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_rpc_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_rpc/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_zendesk/coverage.json

deploy:
name: Release package
Expand Down
7 changes: 5 additions & 2 deletions .releaserc.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ module.exports = {
'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_rails/lib/forest_admin_rails/version.rb; '+
'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/version.rb; '+
'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/version.rb; '+
'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/version.rb; ',
'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/version.rb; '+
'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb; ',
successCmd:
'( cd packages/forest_admin_agent && gem build && gem push forest_admin_agent-*.gem );' +
'( cd packages/forest_admin_datasource_active_record && gem build && gem push forest_admin_datasource_active_record-*.gem );' +
Expand All @@ -37,7 +38,8 @@ module.exports = {
'( cd packages/forest_admin_rails && gem build && gem push forest_admin_rails-*.gem );' +
'( cd packages/forest_admin_datasource_mongoid && gem build && gem push forest_admin_datasource_mongoid-*.gem );' +
'( cd packages/forest_admin_rpc_agent && gem build && gem push forest_admin_rpc_agent-*.gem );' +
'( cd packages/forest_admin_datasource_rpc && gem build && gem push forest_admin_datasource_rpc-*.gem );' ,
'( cd packages/forest_admin_datasource_rpc && gem build && gem push forest_admin_datasource_rpc-*.gem );' +
'( cd packages/forest_admin_datasource_zendesk && gem build && gem push forest_admin_datasource_zendesk-*.gem );' ,
},
],
[
Expand All @@ -56,6 +58,7 @@ module.exports = {
'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/version.rb',
'packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/version.rb',
'packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/version.rb',
'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb',
'package.json'
],
},
Expand Down
7 changes: 7 additions & 0 deletions packages/forest_admin_datasource_zendesk/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
*.gem
.bundle/
Gemfile.lock
coverage/
pkg/
tmp/
.rspec_status
3 changes: 3 additions & 0 deletions packages/forest_admin_datasource_zendesk/.rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
--format documentation
--color
--require spec_helper
42 changes: 42 additions & 0 deletions packages/forest_admin_datasource_zendesk/.rubocop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
inherit_from: ../../.rubocop.yml

# Per-package overrides. The spec suite makes heavy use of RSpec patterns that
# rubocop's defaults flag stylistically but that don't affect correctness:
# - constant aliases at the top of describe blocks for readability (Leaf, Filter, ...)
# - combined stub + call expectation via `expect(x).to receive(...).and_return(...)`
# - "must not be called" assertions via `expect(x).not_to receive(...)`
# Disabling these for spec/** keeps the tests concise without weakening lint
# coverage of the production code.

# Schemas in this package are wide-fact-tables (one ColumnSchema per real
# Zendesk field) and are intrinsically long. Bumping the limits is cheaper
# than splitting them into noise.
Metrics/ClassLength:
Max: 200

Metrics/MethodLength:
Max: 25

Metrics/ParameterLists:
Max: 6

Layout/LineLength:
Max: 130
Exclude:
- 'spec/**/*'

RSpec/StubbedMock:
Exclude:
- 'spec/**/*'

RSpec/MessageSpies:
Exclude:
- 'spec/**/*'

RSpec/LeakyConstantDeclaration:
Exclude:
- 'spec/**/*'

Lint/ConstantDefinitionInBlock:
Exclude:
- 'spec/**/*'
13 changes: 13 additions & 0 deletions packages/forest_admin_datasource_zendesk/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
source 'https://rubygems.org'

gemspec

gem 'forest_admin_datasource_toolkit'
gem 'rake', '~> 13.0'
gem 'rubocop', '~> 1.21'

group :development, :test do
gem 'rspec', '~> 3.0'
gem 'simplecov', '~> 0.22', require: false
gem 'webmock', '~> 3.0'
end
16 changes: 16 additions & 0 deletions packages/forest_admin_datasource_zendesk/Gemfile-test
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
source 'https://rubygems.org'

# Specify your gem's dependencies in forest_admin_datasource_zendesk.gemspec
gemspec

gem 'rake', '~> 13.0'
gem 'rubocop', '~> 1.21'

group :development, :test do
gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit'
gem 'rspec', '~> 3.0'
gem 'simplecov', '~> 0.22', require: false
gem 'simplecov-html', '~> 0.12.3'
gem 'simplecov_json_formatter', '~> 0.1.4'
gem 'webmock', '~> 3.0'
end
6 changes: 6 additions & 0 deletions packages/forest_admin_datasource_zendesk/Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
require 'bundler/gem_tasks'
require 'rspec/core/rake_task'

RSpec::Core::RakeTask.new(:spec)

task default: :spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
lib = File.expand_path('lib', __dir__)
$LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)

require_relative 'lib/forest_admin_datasource_zendesk/version'

Gem::Specification.new do |spec|
spec.name = 'forest_admin_datasource_zendesk'
spec.version = ForestAdminDatasourceZendesk::VERSION
spec.authors = ['Forest Admin']
spec.email = ['contact@forestadmin.com']
spec.homepage = 'https://www.forestadmin.com'
spec.summary = 'Zendesk datasource for Forest Admin Ruby agent.'
spec.description = 'Surface Zendesk tickets, users, organizations and comments as Forest Admin collections.'
spec.license = 'GPL-3.0'
spec.required_ruby_version = '>= 3.0.0'

spec.metadata['homepage_uri'] = spec.homepage
spec.metadata['source_code_uri'] = 'https://github.com/ForestAdmin/agent-ruby'
spec.metadata['changelog_uri'] = 'https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md'
spec.metadata['rubygems_mfa_required'] = 'true'

spec.files = Dir.chdir(__dir__) do
`git ls-files -z`.split("\x0").reject do |f|
(File.expand_path(f) == __FILE__) ||
f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
end
end
spec.bindir = 'exe'
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']

spec.add_dependency 'activesupport', '>= 6.1'
spec.add_dependency 'zeitwerk', '~> 2.3'
spec.add_dependency 'zendesk_api', '~> 3.0'
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
require_relative 'forest_admin_datasource_zendesk/version'
require 'logger'
require 'zeitwerk'
require 'forest_admin_datasource_toolkit'
require 'zendesk_api'

loader = Zeitwerk::Loader.for_gem
loader.setup

module ForestAdminDatasourceZendesk
class Error < StandardError; end
class ConfigurationError < Error; end
class UnsupportedOperatorError < Error; end

# Raised when a Zendesk API call fails for any reason other than the
# well-known `RecordNotFound`. Wraps the underlying error so callers can
# rescue a single class without depending on the zendesk_api gem.
class APIError < Error; end

class << self
attr_writer :logger

def logger
@logger ||= default_logger
end

private

def default_logger
return Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger

Logger.new($stderr).tap { |l| l.progname = 'forest_admin_datasource_zendesk' }
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
module ForestAdminDatasourceZendesk
# HTTP-level wrapper around the zendesk_api gem.
#
# Error policy:
# - "Critical" methods (count, fetch_ticket_comments) raise APIError on any
# failure other than 404. Returning a safe default would silently corrupt
# the data the user is looking at — better to surface a 500 than to mislead.
# - "Best-effort" methods (bulk user/org lookups, schema introspection)
# log a warning and degrade to a safe default. These are enrichment paths;
# missing data shows up as nil/empty in the UI rather than crashing the
# whole page render.
class Client
MAX_PER_PAGE = 100

def initialize(configuration)
@configuration = configuration
end

# ---------- Search (critical) ----------

# `opts` accepts: :query (required), :sort_by, :sort_order, :page,
# :per_page. We use an options hash rather than five named args so the
# call sites (the Searchable mixin) stay tidy and the signature stays
# narrow.
def search(type, **opts)
params = build_search_params(type, opts)
must_succeed("search(#{type})") { api.search(params).to_a }
end

def count(type, query:)
must_succeed("count(#{type})") do
body = api.connection.get('search/count', query: compose_query(type, query)).body
Integer(body['count'] || 0)
end
end

def fetch_ticket_comments(ticket_id)
must_succeed("fetch_ticket_comments(#{ticket_id})") do
Array(api.connection.get("tickets/#{ticket_id}/comments").body['comments'])
end
end

# ---------- Direct fetches (404 -> nil; other errors propagate) ----------

def find_ticket(id)
api.tickets.find(id: id)
rescue ZendeskAPI::Error::RecordNotFound
nil
end

def find_user(id)
api.users.find(id: id)
rescue ZendeskAPI::Error::RecordNotFound
nil
end

def find_organization(id)
api.organizations.find(id: id)
rescue ZendeskAPI::Error::RecordNotFound
nil
end

# ---------- Bulk lookups (best-effort) ----------

def fetch_user_emails(ids)
best_effort('fetch_user_emails', default: {}) do
bulk_show_many('users', ids) { |u| [u['id'], u['email']] }
end
end

def fetch_users_by_ids(ids)
best_effort('fetch_users_by_ids', default: {}) do
bulk_show_many('users', ids) { |u| [u['id'], u] }
end
end

def fetch_organizations_by_ids(ids)
best_effort('fetch_organizations_by_ids', default: {}) do
bulk_show_many('organizations', ids) { |o| [o['id'], o] }
end
end

# ---------- Schema introspection (best-effort; runs at boot) ----------

def fetch_ticket_fields
best_effort('fetch_ticket_fields (custom fields will be unavailable)', default: []) do
Array(api.connection.get('ticket_fields').body['ticket_fields'])
end
end

def fetch_user_fields
best_effort('fetch_user_fields (custom fields will be unavailable)', default: []) do
Array(api.connection.get('user_fields').body['user_fields'])
end
end

def fetch_organization_fields
best_effort('fetch_organization_fields (custom fields will be unavailable)', default: []) do
Array(api.connection.get('organization_fields').body['organization_fields'])
end
end

def raw
api
end

private

def bulk_show_many(resource, ids)
ids = Array(ids).compact.uniq
return {} if ids.empty?

ids.each_slice(MAX_PER_PAGE).with_object({}) do |batch, acc|
body = api.connection.get("#{resource}/show_many", ids: batch.join(',')).body
Array(body[resource]).each do |item|
k, v = yield(item)
acc[k] = v
end
end
end

def must_succeed(operation)
yield
rescue StandardError => e
# find_* methods rescue RecordNotFound themselves and return nil; if a
# 404 reaches us here (for search/count), surface it like any other
# failure rather than silently mapping to nil/zero.
raise APIError, "Zendesk API call failed: #{operation}: #{e.class}: #{e.message}"
end

def best_effort(operation, default:)
yield
rescue StandardError => e
ForestAdminDatasourceZendesk.logger.warn(
"[forest_admin_datasource_zendesk] #{operation} failed; degrading: #{e.class}: #{e.message}"
)
default
end

def compose_query(type, query)
[type ? "type:#{type}" : nil, query.to_s.strip].compact.reject(&:empty?).join(' ')
end

def build_search_params(type, opts)
params = {
query: compose_query(type, opts[:query]),
per_page: [opts[:per_page] || MAX_PER_PAGE, MAX_PER_PAGE].min,
page: opts[:page] || 1
}
params[:sort_by] = opts[:sort_by] if opts[:sort_by]
params[:sort_order] = opts[:sort_order] if opts[:sort_order]
params
end

def api
@api ||= ZendeskAPI::Client.new do |c|
c.url = @configuration.url
c.username = @configuration.username
c.token = @configuration.token
c.retry = true
end
end
end
end
Loading
Loading