-
Notifications
You must be signed in to change notification settings - Fork 1
feat(datasource-pylon): foundation — gem, config & resilient client #341
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8d94a91
320a27f
e6b2340
f07ce77
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| --format documentation | ||
| --color | ||
| --require spec_helper |
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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 |
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium An API error payload like - 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: |
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| @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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
agent-ruby/.github/workflows/build.yml
Line 133 in 8d94a91
The
Send coveragestep referencesreports/3.4-forest_admin_datasource_pylon/coverage.json, but coverage artifacts are uploaded only forruby-version == '4.0', so the artifact is named4.0-forest_admin_datasource_pylon. The3.4path does not exist, so the Pylon coverage file is missing and theqltysh/qlty-action/coveragestep receives a non-existent input. The coverage job matrix uses3.4, which mismatches the4.0upload condition. Either use4.0in thefilespath 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: