-
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
base: feat/datasource-pylon
Are you sure you want to change the base?
Changes from all commits
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,36 @@ | ||
| 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 'activesupport', '>= 6.1' | ||
| spec.add_dependency 'faraday', '~> 2.0' | ||
|
Comment on lines
+32
to
+33
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. 🟠 High The gemspec omits spec.add_dependency 'forest_admin_datasource_toolkit'
+ spec.add_dependency 'activesupport', '>= 6.1'🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| 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,44 @@ | ||
| require_relative 'forest_admin_datasource_pylon/version' | ||
| 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 | ||
| 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,97 @@ | ||
| module ForestAdminDatasourcePylon | ||
| class Client | ||
| RETRY_STATUSES = [429, 502, 503, 504].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. | ||
| IDEMPOTENT_METHODS = %i[delete get head options put].freeze | ||
| RETRY_IF = ->(env, _exception) { env[:status] == 429 } | ||
|
|
||
| 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 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)].compact.join(' ').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']) | ||
| message = parsed.to_json if message.to_s.empty? | ||
| message = "#{message} (request_id: #{parsed["request_id"]})" if parsed['request_id'] | ||
| message.to_s[0, 500] | ||
|
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 | ||
|
|
||
| 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, max: @configuration.max_retries, interval: @configuration.retry_interval, | ||
| backoff_factor: 2, max_interval: 5, retry_statuses: RETRY_STATUSES, | ||
| methods: IDEMPOTENT_METHODS, retry_if: RETRY_IF | ||
| 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,37 @@ | ||
| module ForestAdminDatasourcePylon | ||
| class Configuration | ||
| DEFAULT_BASE_URL = 'https://api.usepylon.com'.freeze | ||
|
|
||
| attr_reader :api_key, :base_url, :open_timeout, :timeout, :max_retries, :retry_interval | ||
|
|
||
| def initialize(api_key:, base_url: nil, open_timeout: 5, timeout: 30, max_retries: 3, retry_interval: 0.5) | ||
|
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 | ||
| @max_retries = max_retries | ||
| @retry_interval = retry_interval | ||
| 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,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: