Skip to content
Merged
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

ruby-version: ["3.4"]

The Send coverage step references reports/3.4-forest_admin_datasource_pylon/coverage.json, but coverage artifacts are uploaded only for ruby-version == '4.0', so the artifact is named 4.0-forest_admin_datasource_pylon. The 3.4 path does not exist, so the Pylon coverage file is missing and the qltysh/qlty-action/coverage step receives a non-existent input. The coverage job matrix uses 3.4, which mismatches the 4.0 upload condition. Either use 4.0 in the files path for Pylon or align the upload and coverage job matrices to the same version.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @.github/workflows/build.yml around line 133:

The `Send coverage` step references `reports/3.4-forest_admin_datasource_pylon/coverage.json`, but coverage artifacts are uploaded only for `ruby-version == '4.0'`, so the artifact is named `4.0-forest_admin_datasource_pylon`. The `3.4` path does not exist, so the Pylon coverage file is missing and the `qltysh/qlty-action/coverage` step receives a non-existent input. The coverage job matrix uses `3.4`, which mismatches the `4.0` upload condition. Either use `4.0` in the `files` path for Pylon or align the upload and coverage job matrices to the same version.

Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
- forest_admin_datasource_zendesk
- forest_admin_datasource_snowflake
- forest_admin_datasource_mambu_payments
- forest_admin_datasource_pylon

steps:
- name: Checkout
Expand Down Expand Up @@ -76,6 +77,7 @@ jobs:
- forest_admin_datasource_zendesk
- forest_admin_datasource_snowflake
- forest_admin_datasource_mambu_payments
- forest_admin_datasource_pylon
services:
mongodb:
image: mongo:latest
Expand Down Expand Up @@ -143,7 +145,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,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_zendesk/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_snowflake/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mambu_payments/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,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_snowflake/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mambu_payments/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_pylon/coverage.json

deploy:
name: Release package
Expand Down
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Gemspec/RequireMFA:
- 'packages/forest_admin_datasource_zendesk/forest_admin_datasource_zendesk.gemspec'
- 'packages/forest_admin_datasource_snowflake/forest_admin_datasource_snowflake.gemspec'
- 'packages/forest_admin_datasource_mambu_payments/forest_admin_datasource_mambu_payments.gemspec'
- 'packages/forest_admin_datasource_pylon/forest_admin_datasource_pylon.gemspec'

# Offense count: 1
# This cop supports unsafe autocorrection (--autocorrect-all).
Expand Down Expand Up @@ -131,6 +132,7 @@ Style/MutableConstant:
- 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb'
- 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/version.rb'
- 'packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb'
- 'packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/version.rb'

# Offense count: 38
# This cop supports safe autocorrection (--autocorrect).
Expand Down Expand Up @@ -214,6 +216,7 @@ Style/StringLiterals:
- 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb'
- 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/version.rb'
- 'packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb'
- 'packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/version.rb'

# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
Expand Down Expand Up @@ -287,6 +290,7 @@ Metrics/ModuleLength:
- 'packages/forest_admin_datasource_customizer/spec/**/*'
- 'packages/forest_admin_datasource_zendesk/spec/**/*'
- 'packages/forest_admin_datasource_mambu_payments/spec/**/*'
- 'packages/forest_admin_datasource_pylon/spec/**/*'
- 'packages/forest_admin_rails/spec/**/*'
- 'packages/forest_admin_rpc_agent/spec/**/*'
- 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb'
Expand Down
8 changes: 8 additions & 0 deletions packages/forest_admin_datasource_pylon/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
*.gem
.bundle/
Gemfile.lock
Gemfile-test.lock
coverage/
pkg/
tmp/
.rspec_status
3 changes: 3 additions & 0 deletions packages/forest_admin_datasource_pylon/.rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
--format documentation
--color
--require spec_helper
16 changes: 16 additions & 0 deletions packages/forest_admin_datasource_pylon/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
source 'https://rubygems.org'

gemspec

gem 'forest_admin_datasource_customizer'
gem 'forest_admin_datasource_toolkit'
gem 'rake', '~> 13.0'
gem 'rubocop', '1.86.1'
gem 'rubocop-performance', '1.26.1'
gem 'rubocop-rspec', '3.9.0'

group :development, :test do
gem 'rspec', '~> 3.0'
gem 'simplecov', '~> 0.22', require: false
gem 'webmock', '~> 3.0'
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 17 lines of identical code in 3 locations (mass = 76) [qlty:identical-code]

19 changes: 19 additions & 0 deletions packages/forest_admin_datasource_pylon/Gemfile-test
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
source 'https://rubygems.org'

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

gem 'rake', '~> 13.0'
gem 'rubocop', '1.86.1'
gem 'rubocop-performance', '1.26.1'
gem 'rubocop-rspec', '3.9.0'

group :development, :test do
gem 'forest_admin_datasource_customizer', path: '../forest_admin_datasource_customizer'
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_pylon/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_pylon/version'

Gem::Specification.new do |spec|
spec.name = 'forest_admin_datasource_pylon'
spec.version = ForestAdminDatasourcePylon::VERSION
spec.authors = ['Forest Admin']
spec.email = ['contact@forestadmin.com']
spec.homepage = 'https://www.forestadmin.com'
spec.summary = 'Pylon datasource for Forest Admin Ruby agent.'
spec.description = 'Surface Pylon issues, accounts, contacts, users and teams 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'] = 'false'

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 'faraday', '~> 2.0'
spec.add_dependency 'faraday-retry', '~> 2.0'
spec.add_dependency 'zeitwerk', '~> 2.3'
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
require_relative 'forest_admin_datasource_pylon/version'
require 'json'
require 'logger'
require 'zeitwerk'
require 'faraday'
require 'faraday/retry'
require 'forest_admin_datasource_toolkit'

loader = Zeitwerk::Loader.for_gem
loader.setup

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

# Raised when a Pylon API call fails. Carries the HTTP status and the
# (parsed) response body so callers — smart actions in particular — can
# surface Pylon's own validation message instead of a generic string.
class APIError < Error
attr_reader :status, :body

def initialize(message, status: nil, body: nil)
super(message)
@status = status
@body = body
end
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_pylon' }
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 34 lines of similar code in 2 locations (mass = 102) [qlty:similar-code]

end
Comment thread
qltysh[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
module ForestAdminDatasourcePylon
class Client
def initialize(configuration)
@configuration = configuration
end

# Health check: Pylon returns the details of the organization owning the
# token, which is enough to prove the credentials are usable.
def me
must_succeed('me') { extract_data(connection.get('me').body) }
end

private

# Pylon wraps payloads in { "data": ..., "pagination": ..., "request_id": ... }.
def extract_data(body)
return nil if body.nil? || body == ''
return body['data'] if body.is_a?(Hash) && body.key?('data')

body
end

def must_succeed(operation)
yield
rescue Faraday::Error => e
raise api_error(operation, e)
rescue APIError
# Already mapped, with its status intact; re-wrapping would erase it.
raise
rescue StandardError => e
raise APIError, "Pylon API call failed: #{operation}: #{e.class}: #{e.message}"
end

# Builds an APIError preserving the HTTP status and Pylon's own error body so
# smart actions can show the operator the real reason instead of "failed".
def api_error(operation, error)
response = error.respond_to?(:response) ? error.response : nil
status = response.is_a?(Hash) ? response[:status] : nil
body = parse_body(response.is_a?(Hash) ? response[:body] : nil)
detail = error_detail(status, body) || "#{error.class}: #{error.message}"
APIError.new("Pylon API call failed: #{operation}: #{detail}", status: status, body: body)
end

def error_detail(status, body)
return nil unless status

"HTTP #{status} #{error_message(body)}".strip
end

def error_message(parsed)
return parsed.to_s[0, 500] unless parsed.is_a?(Hash)

nested = parsed['error']
message = parsed['message'] || (nested.is_a?(Hash) ? nested['message'] : nested) ||
join_errors(parsed['errors'])
Comment on lines +53 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium forest_admin_datasource_pylon/client.rb:53

An API error payload like { "message": "", "errors": [{ "message": "invalid field" }] } surfaces the serialized whole payload instead of the validation message from errors. parsed['message'] is an empty string, which is truthy in Ruby, so error_message keeps the empty value and append_request_id falls through to parsed.to_json — bypassing join_errors(parsed['errors']) which would have returned the real error text. Use presence (or a blank check) so empty-string candidates are treated as absent and the fallback chain proceeds.

-      nested = parsed['error']
-      message = parsed['message'] || (nested.is_a?(Hash) ? nested['message'] : nested) ||
-                join_errors(parsed['errors'])
+      nested = parsed['error']
+      message = parsed['message'].presence ||
+                (nested.is_a?(Hash) ? nested['message'].presence : nested&.presence) ||
+                join_errors(parsed['errors'])
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb around lines 53-55:

An API error payload like `{ "message": "", "errors": [{ "message": "invalid field" }] }` surfaces the serialized whole payload instead of the validation message from `errors`. `parsed['message']` is an empty string, which is truthy in Ruby, so `error_message` keeps the empty value and `append_request_id` falls through to `parsed.to_json` — bypassing `join_errors(parsed['errors'])` which would have returned the real error text. Use `presence` (or a blank check) so empty-string candidates are treated as absent and the fallback chain proceeds.

message = parsed.to_json if message.to_s.empty?
# Truncate before appending: the request_id is what support needs, so it
# must not be the first thing a long error body pushes out.
append_request_id(message.to_s[0, 500], parsed['request_id'])
end

def append_request_id(message, request_id)
return message unless request_id

"#{message} (request_id: #{request_id})"
end

def join_errors(errors)
Array(errors).filter_map { |e| e.is_a?(Hash) ? (e['message'] || e['detail']) : e }.join('; ')
end

def parse_body(body)
return body unless body.is_a?(String) && !body.empty?

JSON.parse(body)
rescue JSON::ParserError
body
end

# Middleware order is deliberate: `raise_error` sits outside the JSON parser
# so it raises with an already-parsed body, and `retry` sits innermost so it
# inspects raw statuses — behind `raise_error` it would never see a 429.
def connection
@connection ||= Faraday.new(url: @configuration.url) do |f|
f.request :json
f.response :raise_error
f.response :json
f.request :retry, **@configuration.retry_policy.to_faraday_options
f.headers['Authorization'] = "Bearer #{@configuration.api_key}"
f.headers['Accept'] = 'application/json'
f.headers['User-Agent'] = "forest_admin_datasource_pylon/#{VERSION}"
f.options.open_timeout = @configuration.open_timeout
f.options.timeout = @configuration.timeout
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
module ForestAdminDatasourcePylon
class Configuration
DEFAULT_BASE_URL = 'https://api.usepylon.com'.freeze

attr_reader :api_key, :base_url, :open_timeout, :timeout, :retry_policy

def initialize(api_key:, base_url: nil, open_timeout: 5, timeout: 30, retry_policy: RetryPolicy.new)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 5): initialize [qlty:function-parameters]

@api_key = api_key
@base_url = base_url || DEFAULT_BASE_URL
@open_timeout = open_timeout
@timeout = timeout
@retry_policy = retry_policy
validate!
end

# Pylon exposes unversioned paths (`/issues`, `/me`) directly under the host.
def url
@base_url.chomp('/')
end

private

def validate!
missing = []
missing << 'api_key' if blank?(@api_key)
return if missing.empty?

raise ConfigurationError,
"ForestAdminDatasourcePylon missing required config: #{missing.join(", ")}"
end

def blank?(value)
value.nil? || value.to_s.strip.empty?
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
module ForestAdminDatasourcePylon
# Everything governing how the client reacts to a failed request, in one
# place: which statuses and exceptions are worth another attempt, on which
# verbs, and how long to wait.
class RetryPolicy
# Pylon quotas are per-minute, so a 429 routinely carries a Retry-After of up
# to 60s. faraday-retry gives up outright when Retry-After exceeds
# max_interval (`return if retry_after > max_interval`), so the cap has to
# cover a full rate-limit window or the 429 retry never fires when it matters.
DEFAULT_MAX_INTERVAL = 65

STATUSES = [429, 502, 503, 504].freeze

# faraday-retry's defaults plus ConnectionFailed: a dropped connection is
# exactly the transient failure a resilient client should absorb, and it is
# not retried out of the box.
EXCEPTIONS = [
Errno::ETIMEDOUT, 'Timeout::Error', Faraday::TimeoutError,
Faraday::RetriableResponse, Faraday::ConnectionFailed
].freeze

# Faraday only retries these by default; a 429 is safe to retry on any verb
# because Pylon rejected the request before processing it, whereas a 502 on a
# POST /issues may well have created the issue. This has to go through
# retry_if rather than methods: faraday-retry ORs the two, so methods can
# only widen the set, never restrict it.
IDEMPOTENT_METHODS = %i[delete get head options put].freeze
RETRY_IF = ->(env, _exception) { env[:status] == 429 }

BACKOFF_FACTOR = 2

attr_reader :max_retries, :interval, :max_interval

def initialize(max_retries: 3, interval: 0.5, max_interval: DEFAULT_MAX_INTERVAL)
@max_retries = max_retries
@interval = interval
@max_interval = max_interval
end

def to_faraday_options
{
max: @max_retries,
interval: @interval,
max_interval: @max_interval,
backoff_factor: BACKOFF_FACTOR,
retry_statuses: STATUSES,
exceptions: EXCEPTIONS,
methods: IDEMPOTENT_METHODS,
retry_if: RETRY_IF
}
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module ForestAdminDatasourcePylon
VERSION = "1.36.2"
end
Loading
Loading