Skip to content

feat(datasource-pylon): Pylon datasource for the Ruby agent (EXT-4) - #369

Open
christophebrun-forest wants to merge 30 commits into
mainfrom
feat/datasource-pylon
Open

feat(datasource-pylon): Pylon datasource for the Ruby agent (EXT-4)#369
christophebrun-forest wants to merge 30 commits into
mainfrom
feat/datasource-pylon

Conversation

@christophebrun-forest

@christophebrun-forest christophebrun-forest commented Aug 24, 2026

Copy link
Copy Markdown
Member

Integration branch for EXT-4forest_admin_datasource_pylon, feature parity with the Zendesk datasource.

The ten stories of the epic are Done and merged, main is merged in, and the branch touches exactly three files outside the new package.

What is in the branch

A new forest_admin_datasource_pylon package: 44 files under lib, 24 spec files, 786 examples, 100.0% line coverage (1686/1686) and 94.5% branch coverage. Ten story PRs, all reviewed and merged into this branch:

PR Story Content
#341 EXT-5 foundation — gem, configuration, resilient client
#347 EXT-6 PylonIssue read-only, cursor pagination
#351 EXT-7 filters, search, count, PK short-circuit hardening
#354 EXT-8 all collections + relations
#358 EXT-9 messages / conversation thread
#359 EXT-10 custom fields introspection
#362 EXT-11 write operations (CRUD)
#366 EXT-12 action plugins: close issue, create issue with notification
#373 EXT-13 story 9 — proactive per-endpoint throttling on top of the 429 retry
#375 EXT-17 bound the boot-time custom-fields introspection

That is every sub-issue of EXT-4 and every PR ever opened against this branch. Re-checked against
Linear on 2026-08-27: EXT-4 has exactly these ten children, all Done, and no eleventh — so the
table above is the whole epic. EXT-4 itself is the only thing left In Progress, this PR being what
closes it.

Three follow-ups were filed out of the review rather than fixed here. They are not sub-issues of
EXT-4 — each is repo-wide, not Pylon's — and they are listed so the table is the full Linear picture:

Ticket Scope Why not here
EXT-19 .releaserc.js chains all fourteen successCmd pushes with ; Fixing only the mambu/pylon boundary the diff touches would leave twelve packages masking failures
EXT-20 No datasource gemspec declares the Forest gems as runtime deps Declaring them on Pylon alone makes it the only gem with a different contract
EXT-21 A non-positive page limit reads as "no limit" in one place, "empty" in another Read path of every collection; the two readings have to be settled together

Shape of the package: client.rb + client/writes.rb over the Pylon API with a retry_policy, a rate_limiter / rate_limits / throttle trio metering each endpoint in its own sliding window, a base_collection specialised into cursor_collection / fetch_all_collection, one directory per collection (account, contact, issue, team, user) each with its own api_filters / schema_definition / serializer, a query/ layer translating condition trees into Pylon filters, pagination/cursor_walker, schema/custom_fields_introspector, and two action plugins.

Review pass on the whole branch

65190b9f merges main; the two conflicts were both the #368 sort-decorator cherry-pick, resolved in favour of main, so those files have left this diff. Fifteen commits then close out the review of the assembled branch — these are direct commits, so they appear in no story PR above:

Commit Kind Content
b9cb7d15 fix refuse a Number filter cast to Infinity / NaN; deduplicate action issue ids; stop collect_pages on a cursor already followed
a93474b1 fix MAX_ID_LOOKUPS bounds a page rather than a selection
24951097 fix refuse two plugin configurations that misbehave silently
cc015514 fix keep no column for a projection naming only relations
373f94de refactor drop the countable: flag nothing opts into
7033ba1d fix stop the custom-field introspection at its first failure
bd55e8ba docs the package README, last open item of EXT-13
2a1b4d6d fix serve the whole record for an empty projection
67cd786a fix refuse an internal note the form never offered
1fd03cbd fix keep a select with no option left read-only
96a0575a docs record what bounds CloseIssue and what does not
0a2d4e4d chore align version.rb with the other packages
d75bab2c fix intersect the id conditions of an and, on both the read and the write path
524cc95e fix let a refused selection reach the operator of a close action
b6bd085b docs correct which scope bounds CloseIssue

Defects fixed

  • b9cb7d15 — a Number filter the agent cast to Infinity or NaN reached to_i, which raises, and then the JSON encoder, which raises too: a 500 either way, on a value the operator typed. Now refused with a 400 naming the field. Same commit: an action reading issue ids off a column deduplicates them, and collect_pages stops on a cursor it has already followed rather than only on one that did not move.
  • a93474b1MAX_ID_LOOKUPS bounded a selection where it should bound a page: the ids were truncated to the first 20 before the window was applied, so an offset at or past 20 answered an empty page from records that exist. The window is now taken off the ids before any of them is read, which also spends one request per record the caller asked to see. A lookup carrying a residual condition cannot be paged that way and is refused past the cap rather than answered with a fraction of itself.
  • 24951097 — a plugin scope that is neither a symbol nor a string raised NoMethodError instead of the error naming it; duplicate email-template titles, and a template titled like the "pick none" sentinel, are now refused at registration instead of silently sending the wrong message.
  • cc015514project served the whole record when the projection named no column of the collection, so a row asked for as account:name came back carrying every native column too. Relevant now that main has landed serve only the columns of collections the caller may read. It also changed what an empty projection returns, which is not that case at all; 2a1b4d6d separates the two.
  • 7033ba1d — EXT-17 bounded each boot-time introspection per request, not the three of them together, so a Pylon that hangs could add about a minute to a Rails boot rather than the twenty seconds intended. The first failure now stands for the object types after it.

Also

  • 373f94de — drops the countable: flag: no collection ever passed it, Pylon having no count endpoint and no total.
  • bd55e8ba — the package README, last open item of EXT-13.

Macroscope: 21 review threads across seven passes, every one answered in thread. The seventh pass landed one finding, on base_collection.rb:107, and it was real: fixed in d75bab2c, described below. Two of the last four findings were real and are fixed (67cd786a, 1fd03cbd); one is a false positive (nil.to_i is 0, so the guard already re-raises) and one is not reachable through the agent stack, which rewrites the date operators upstream — both answered with the reasoning. The normalize_templates thread was Macroscope reading a stale index; the method has been there since 24951097.

Recorded on Linear rather than fixed here: SnoozeIssue (EXT-12) and aggregate / Count (EXT-7) are in the epic scope but were never delivered; both are commented on their story with what exists instead. EXT-4's rate-limit figures were low by a factor of 4 to 15 and have been corrected against rate_limits.rb. The three findings declined as repo-wide now have tickets of their own, so they outlive these threads: EXT-19 (.releaserc.js chaining with ;), EXT-20 (no datasource gemspec declares the Forest gems) and EXT-21 (a non-positive page limit).

Behaviour outside the new package

Three files, each additively, to register the gem: .github/workflows/build.yml (lint matrix, test matrix, codecov files), .releaserc.js (prepareCmd, successCmd, git assets) and .rubocop.yml (MFA opt-out, the version.rb string cops, and the per-file metric excludes the package actually trips). All four points of CLAUDE.md are covered and verified.

Verification

Re-run locally on 0a2d4e4d, Ruby 4.0.2:

  • forest_admin_datasource_pylon: 790 examples, 0 failures. Line coverage 100.0% (1691/1691). RuboCop 71 files, 0 offense.
  • Earlier on the merged tree: forest_admin_datasource_customizer 703 examples, forest_admin_datasource_toolkit 478, forest_admin_agent 1164 — all 0 failures.

.releaserc.js was loaded through node to confirm both prepareCmd and successCmd are valid concatenations, and both YAML files were parsed. forest_admin_datasource_pylon is unclaimed on RubyGems, so the first publish creates it.

Three fixes from the seventh pass

Local re-run on b6bd085b, Ruby 4.0.2: 794 examples, 0 failures, line coverage 100.0% (1695/1695), branch 94.3%, RuboCop 71 files / 0 offense.

  • d75bab2c — an and names the records all of its conditions name, so two id leaves name their intersection. Both the lookup short-circuit and the write-resolution bound kept only the first and left the second to be applied later, which put the cap around the wider set: a selection of 25 ids narrowed to 3 was refused over the 25 it named, after reading all 25 one request each. Worth being exact about the failure — the refusal is an explicit UnsupportedOperatorError, never a short page, so this over-refused rather than under-delivered. extract_id_lookup now partitions the id leaves out of the branch and intersects them, leaving the residual, ensure_residual_appliable! and guard_nil_comparisons untouched; disjoint sets reduce to the empty lookup with no request at all. writes.rb#filtered_ids moves with it, that count being what refuse_unresolvable_selection is worded around — its own comment had been excusing the same shape. Narrow on the read side: only PylonIssue reaches the and branch, cursor collections short-circuiting on a bare leaf alone.
  • 524cc95eIssueTargets.resolve_issue_ids rescued StandardError and answered [], which the action reports as "No Pylon issue selected." That swallowed the datasource's own refusals: a bulk CloseIssue on PylonIssue, with a scope or segment set and more than MAX_ID_LOOKUPS records selected, raises inside list and the operator was told nothing was selected about a selection they can see they made. A Pylon outage read the same way. A ValidationError now travels, the agent surfacing its message; everything else still degrades, so a renamed column is still "no issue selected" rather than a stack trace in the panel.
  • b6bd085b — the README's CloseIssue caveat had the scope question backwards. It said a scope on PylonIssue does not bound what the action closes, on the grounds that the state is written through the client. The ids are resolved first, through the collection the action sits on, and Actions#get_record_selection intersects the operator's scope into the filter that reads them — so mounted on PylonIssue, the scope bounds exactly what it closes. What the caveat describes is the host-collection form, where the ids come off a column and that column is the authority. As written it told an operator a scope bought them nothing when it bought them the whole selection.

Open items before merge

  • CI is green on the current head. Run 33084670532 on b6bd085b, and 33076526570 before it on 0a2d4e4d: 55 jobs successful, one skipped (Release package, which only runs on main / beta). Both pylon lint jobs and both pylon test jobs pass, Ruby 3.4 included — the earlier runs for 65190b9f and bd55e8ba had waited hours on a runner without ever starting, so this is the first run to exercise the branch on anything but 4.0.
  • Deliberately not fixed here, each with the reasoning recorded in thread:
    • CloseIssue's batch is uncapped: one request per selected record, so a wide bulk selection is a long sequential run the request may time out on, leaving what it already closed closed. Stated in the README (96a0575a, reworded in b6bd085b); putting a cap on a bulk action is a product call, not a defect fix. Worth knowing that Writes::MAX_WRITE_REQUESTS refuses exactly this shape at 20 requests on the collection itself, so the datasource currently answers the same question two ways.
    • NIL_UNSAFE_OPERATORS and residual_leaf_appliable? are derived from two different sets, so the guard is narrower than what the residual admits and only the upstream OperatorsEquivalence rewrite closes the gap. Not reachable today; deriving the guard from the equivalence touches the filtering path of every collection.
    • Configuration#validate! checks neither that base_url parses nor that it is https, so a typo surfaces as a raw URI error at the first request and an http:// base url would carry the bearer token in clear. Refusing http would break local mock servers, so this wants a warning rather than a refusal, in its own change.
  • A human review.

Left for a follow-up

  • One hardening for the base_url change, not done here: Configuration exposes api_key through attr_reader and defines no inspect, so any incidental inspect of a Configuration, Client or Datasource — a Rails error page, a logger.debug of the datasource — prints the bearer token. Same family as the http:// item above, same one-line place to fix it.

🤖 Generated with Claude Code

Note

Add forest_admin_datasource_pylon package with collections, client, and plugins

  • Introduces a complete Pylon datasource for the Ruby agent: Datasource, Configuration, Client, and five collections (PylonIssue, PylonAccount, PylonContact, PylonUser, PylonTeam) with cursor-based and fetch-all base classes
  • Adds query translation (ConditionTreeTranslator, FilterValue, OperatorMaps), rate limiting (RateLimiter, RateLimits, Throttle), retry policy, and custom-field introspection
  • Includes two action plugins: CloseIssue (single/bulk) and CreateIssueWithNotification (form builder + payload shaping), plus IssueTargets resolver and shared Writes module with write budgets
  • Ships full RSpec test coverage across client, collections, plugins, pagination, and configuration; CI workflow and release config updated to build/publish the gem
  • Risk: new package with no prior consumers; reviewers should verify operator allow-lists in collections/*/api_filters.rb and rate-limit budgets in rate_limits.rb match the live Pylon API contract

Macroscope summarized bd55e8b.

christophebrun-forest and others added 11 commits August 7, 2026 18:23
…341)

* feat(pylon): scaffold datasource gem with config and resilient client

Story 1 of the Pylon datasource (EXT-5).

Adds the forest_admin_datasource_pylon gem skeleton: Zeitwerk autoloading,
typed error hierarchy with an APIError carrying HTTP status and parsed body,
configurable logger, Configuration with api_key validation, and a Faraday
client authenticating with a Bearer token plus a GET /me health check.

The Faraday middleware order is deliberate and differs from the Mambu
Payments gem: raise_error sits outside the JSON parser so errors carry an
already-parsed body, and retry sits innermost so it can observe raw statuses.
Behind raise_error the retry middleware never sees a 429 and retry_statuses
silently does nothing. Non-idempotent verbs are only retried on 429, where
Pylon rejected the request before processing it.

Wires the package into the CI lint, test and coverage jobs. The
semantic-release publish pipeline is intentionally left untouched until
Story 9, so an incomplete gem is never pushed to RubyGems.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…agination (#347)

* feat(pylon): add PylonIssue read-only collection with cursor pagination

Registers the first Pylon collection: issues in list + record-detail mode,
backed by POST /issues/search and GET /issues/{id}.

Forest asks for an offset/limit window while Pylon only hands out the next
page of a cursor, so CursorWalker walks pages until the window is covered
then slices. The walk is capped (20 pages / 5000 records, with a truncation
warning) because /issues/search allows 20 requests per minute and an
uncapped deep-offset walk would spend an agent's whole budget on one list
view. It also stops defensively on an empty page or a cursor that does not
advance.

The schema follows the live API rather than the ticket: Pylon has no
priority field, first_response_time/resolution_time are RFC3339 timestamps
and not durations, and /issues/search exposes no sort parameter, so no
column is sortable and translate_sort is not ported from Zendesk. Nested
account/requester/assignee/team objects are flattened into id columns until
the related collections exist.

Search and Count default to disabled in BaseCollection, the inverse of the
Zendesk template, since both land with the condition-tree translator. Until
then a condition the collection cannot honour is dropped with a warning
naming what was discarded, so an unfiltered result set does not read as a
filtered one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#351)

* feat(datasource): translate Forest filters for Pylon search

PylonIssue honoured no filter but the primary-key short-circuit, so a
segment or a UI filter returned unfiltered rows that looked filtered.

- Query::ConditionTreeTranslator maps a condition tree onto the structured
  JSON filter of POST /issues/search: native OR through subfilters, a
  depth-3 guard, and timezone-aware date bounds. Anything the API cannot
  express raises instead of being dropped.
- Issue::ApiFilters transcribes Pylon's per-field allow-list and is the
  single source of truth: define_schema derives every filter_operators
  from it, so the schema can no longer advertise a filter the API refuses.
  Declaring the bare comparisons on date columns lets the toolkit rewrite
  Today / Previous* into a pair of bounds, so time_range is never emitted.
- filter.search is forwarded as search_text (enable_search).
- extract_id_lookup also pulls the id leaf out of a top-level AND and
  returns the leftover conditions, applied in memory. Forest sends
  AND(id equal X, scope) on a record detail as soon as a scope is set, and
  id is not a Pylon filter field.
- translate_sort / timezone_for ported from the Zendesk base collection.
  /issues/search has no sort parameter, so PylonIssue's allow-list is empty
  and a requested order is reported rather than silently swallowed.

Count stays disabled: Pylon exposes neither a count endpoint nor a total,
so counting means walking pages against a 20 req/min budget on every list
view. Moved to the hardening story that owns throttling.

176 examples, 0 failures, 100% line coverage, rubocop clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gem was registered in build.yml and .rubocop.yml but not in
.releaserc.js, so semantic-release never bumped its version, never built
it and never pushed it, while CI stayed green: the omission has no
failing check to surface it. Added to the three places a package has to
appear -- the prepareCmd sed, the successCmd build and push, and the
git assets, so the bumped version.rb travels with the release commit.

query/filter_value.rb requires active_support/core_ext/time/zones and
calls Time.use_zone, which only the monorepo Gemfile was providing. The
zendesk and mambu datasources both declare activesupport >= 6.1; without
it, installing the gem on its own gives a datasource that raises on the
first date filter it translates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(pylon): client endpoints for accounts, contacts, users and teams

Adds search/list/fetch methods for the four new resources, routed
through shared private helpers; search_issues and fetch_issue now
delegate to the same helpers with unchanged behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(pylon): embed the issue conversation thread

Ports the Zendesk comments embedder to Pylon: the messages of an issue are
read through GET /issues/{id}/messages and embedded as a structured array
column on PylonIssue.

The thread is asked for without a limit, so Pylon answers with the whole
conversation in one request; a page would have handed back the oldest
messages and cut the recent ones off. Authors are flattened from the payload
Pylon already nests in each message, so no author lookup is spent at all.

The fan-out is bounded like the primary-key lookups of this collection: the
endpoint allows 20 requests per minute and a thread costs one request per
row, so rows past MAX_MESSAGE_EMBEDS are left at nil -- unknown, never the
empty list, which would read as "this issue has no message". A thread that
cannot be read degrades the same way instead of failing the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: bound every job and harden the unixODBC install

The workflow declared no timeout anywhere, so a job that hangs pins the
run for GitHub's 6 hour default. A hung `apt-get` on the two Snowflake
lint legs did exactly that: `test` needs `lint`, and `coverage` needs
`test`, so one stuck leg of the matrix froze the whole pipeline and no
test ever ran.

Lint, test and coverage now carry a timeout, and the apt step that hangs
carries its own, tighter one, plus retries on the mirror and a
noninteractive frontend. A failing leg is now visible in minutes and can
be re-run.

Deploy is left unbounded on purpose: cutting semantic-release off in the
middle of pushing thirteen gems is worse than waiting for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c5df079)
* feat(pylon): client endpoint for custom fields

GET /custom-fields takes a mandatory object_type, so the definitions of
each collection are read by their own call. The walk carries the
parameter on every page: dropped on the second request, it would answer
for another object type or with a 400.

Degrades to an empty list, like the message thread: this is read while
the agent boots, and a token missing the permission has to cost the
operator the custom columns rather than the whole datasource.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(pylon): write operations (CRUD)

Open the datasource to writes: a `writes` mixin on the client, one method
per Pylon endpoint, and create/update/delete on every collection, through
a mechanism shared by the three collection bases.

What may be written is `is_read_only` on the column, the single source of
truth the payload builder reads, the way `api_filters` already is for
filtering. Pylon's own `is_read_only` is now honoured on a custom field,
and a value is written back through the list of `{slug, value}` entries
the API takes, `values` for a multiselect and the option slug for a
select.

The verbs Pylon exposes no endpoint for -- no POST or DELETE on a user,
no DELETE on a team -- refuse with a message rather than the contract's
NotImplementedError, which the agent answers as an unexpected 500. So do
the fields it only accepts in one direction: `body_html` on a create,
`state` on an update, and the like, dropped when they ask for nothing and
refused when the operator really changed them.

A filter-driven update or delete resolves its ids exactly or refuses:
an id filter is answered without a request, anything else goes through
the collection's own list so the scope applies, and a selection wider
than one pass of writes is refused rather than written halfway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gins (EXT-12) (#366)

* feat(pylon): close and create-with-notification action plugins

Two plugins at parity with the Ruby Zendesk ones, built on the write
primitives of the previous commit.

CloseIssue moves the selected issues to a state, `closed` unless told
otherwise -- a custom status slug is taken as readily, Pylon accepting
one wherever it accepts a standard state. One variant per scope rather
than the four Zendesk builds, there being a single terminal state here.
Each id is written under its own rescue and named in the message, so a
batch that only half applied never reads as a plain success.

CreateIssueWithNotification opens an issue and delivers its first
message. Pylon says the delivery outright where Zendesk infers it from a
public comment: `destination_metadata.destination` names the channel, and
no metadata at all is what leaves the issue internal, which is what the
"send as internal note" checkbox writes. The form carries no Type field,
`POST /issues` taking none, and the priority it does carry is never read
back -- no Pylon read returns one.

Both find the issues to act on through one option: `issue_id_field` names
a column of the host collection, and its absence falls back to the
primary keys, which is what an action registered on PylonIssue acts on.

Snooze is left out until its endpoint is confirmed against a live
organization; IssueTargets and the messages module are already shared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…comments

Review follow-up on the previous commit, no behaviour change.

Building the fields hash with to_h removes both the reassignment of a
key while the hash is being iterated and the now useless dup of it, and
the comments are cut back to the two non-obvious points: the key? read
and the copy before the write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 24, 2026

Copy link
Copy Markdown

EXT-4

Comment thread .releaserc.js
'( cd packages/forest_admin_datasource_zendesk && gem build && gem push forest_admin_datasource_zendesk-*.gem );' +
'( cd packages/forest_admin_datasource_snowflake && gem build && gem push forest_admin_datasource_snowflake-*.gem );' +
'( cd packages/forest_admin_datasource_mambu_payments && gem build && gem push forest_admin_datasource_mambu_payments-*.gem );' ,
'( cd packages/forest_admin_datasource_mambu_payments && gem build && gem push forest_admin_datasource_mambu_payments-*.gem );' +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High .releaserc.js:47

A failed forest_admin_datasource_mambu_payments build or push is reported as a successful successCmd when the subsequent Pylon publish succeeds, so semantic-release can mark a partially published release as successful. The Mambu subshell is separated from the Pylon subshell with ;, making Pylon's status the overall status; chain them with && so the Mambu failure propagates.

Suggested change
'( cd packages/forest_admin_datasource_mambu_payments && gem build && gem push forest_admin_datasource_mambu_payments-*.gem );' +
'( cd packages/forest_admin_datasource_mambu_payments && gem build && gem push forest_admin_datasource_mambu_payments-*.gem ) &&' +
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @.releaserc.js around line 47:

A failed `forest_admin_datasource_mambu_payments` build or push is reported as a successful `successCmd` when the subsequent Pylon publish succeeds, so semantic-release can mark a partially published release as successful. The Mambu subshell is separated from the Pylon subshell with `;`, making Pylon's status the overall status; chain them with `&&` so the Mambu failure propagates.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid, but not this PR's to fix.

All fourteen packages in successCmd are chained with ;, and have been since long before this branch — the mambu/pylon boundary is just where the diff happens to touch the list. Changing only that one separator would leave the other twelve masking failures exactly as they do today, so the fix belongs in a repo-wide change against main.

The underlying point stands and is worth its own ticket: a gem push failing anywhere in the chain is currently reported as a successful release.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agreed — fixing all fourteen packages consistently makes more sense than patching just the diff boundary. Would you like me to open a PR against main that chains all the successCmd gem operations with && instead of ; so any build or push failure properly propagates?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Filed as EXT-19 so it outlives this thread — all fourteen packages, as a change against main.

spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']

spec.add_dependency 'activesupport', '>= 6.1'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High forest_admin_datasource_pylon/forest_admin_datasource_pylon.gemspec:32

Installing this gem from RubyGems omits forest_admin_datasource_toolkit and forest_admin_datasource_customizer, so require 'forest_admin_datasource_pylon' fails with LoadError in a clean application. The development Gemfile does not provide runtime gem dependencies; declare both Forest gems in this gemspec.

  spec.add_dependency 'activesupport', '>= 6.1'
+  spec.add_dependency 'forest_admin_datasource_toolkit'
+  spec.add_dependency 'forest_admin_datasource_customizer'
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_pylon/forest_admin_datasource_pylon.gemspec around line 32:

Installing this gem from RubyGems omits `forest_admin_datasource_toolkit` and `forest_admin_datasource_customizer`, so `require 'forest_admin_datasource_pylon'` fails with `LoadError` in a clean application. The development `Gemfile` does not provide runtime gem dependencies; declare both Forest gems in this gemspec.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Acknowledged, deliberately consistent with the monorepo.

No datasource gem here declares the Forest gems as runtime dependencies — zendesk, graphql_hasura, snowflake and mambu_payments all omit both, and every one of them requires the toolkit at load exactly as this one does. They are brought in by the agent application through forest_admin_rails / forest_admin_agent.

Declaring them on Pylon alone would make it the only gem in the repo with a different dependency contract, without fixing the other four. If the contract is wrong it is wrong repo-wide, which is a separate change against main.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Filed as EXT-20 so it outlives this thread — all five datasource gemspecs, with the pinning question stated rather than assumed.

Brings the 29 commits main gained since 1.36.2, the merge base.

Three files were touched on both sides and conflicted, all three because
main registered forest_admin_datasource_graphql_hasura where this branch
registered forest_admin_datasource_pylon. Every resolution is additive,
both packages kept:

- .releaserc.js: prepareCmd, successCmd and the git assets list
- .github/workflows/build.yml: lint matrix, test matrix, coverage files
- .rubocop.yml: the Gemspec/RequireMFA exclusion

main also put forest_admin_rails back in the test matrix and moved the
coverage job to Ruby 4.0; both come over untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qltysh

qltysh Bot commented Aug 25, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 0.6%.

Modified Files with Diff Coverage (43)

RatingFile% DiffUncovered Line #s
New file Coverage rating: A
...forest_admin_datasource_pylon/collections/cursor_collection.rb100.0%
New file Coverage rating: A
...pylon/lib/forest_admin_datasource_pylon/collections/contact.rb100.0%
New file Coverage rating: A
...orest_admin_datasource_pylon/collections/contact/serializer.rb100.0%
New file Coverage rating: A
..._pylon/lib/forest_admin_datasource_pylon/query/filter_value.rb100.0%
New file Coverage rating: A
...tasource_pylon/lib/forest_admin_datasource_pylon/datasource.rb100.0%
New file Coverage rating: A
...pylon/lib/forest_admin_datasource_pylon/query/operator_maps.rb100.0%
New file Coverage rating: A
..._pylon/lib/forest_admin_datasource_pylon/collections/writes.rb100.0%
New file Coverage rating: A
...st_admin_datasource_pylon/lib/forest_admin_datasource_pylon.rb100.0%
New file Coverage rating: A
...b/forest_admin_datasource_pylon/collections/base_collection.rb100.0%
New file Coverage rating: A
...asource_pylon/lib/forest_admin_datasource_pylon/rate_limits.rb100.0%
New file Coverage rating: A
...ce_pylon/lib/forest_admin_datasource_pylon/collections/team.rb100.0%
New file Coverage rating: A
...ce_pylon/lib/forest_admin_datasource_pylon/collections/user.rb100.0%
New file Coverage rating: A
...rest_admin_datasource_pylon/collections/contact/api_filters.rb100.0%
New file Coverage rating: A
...est_admin_datasource_pylon/collections/fetch_all_collection.rb100.0%
New file Coverage rating: A
..._admin_datasource_pylon/collections/issue/messages_embedder.rb100.0%
New file Coverage rating: A
...e_pylon/plugins/create_issue_with_notification/form_builder.rb100.0%
New file Coverage rating: A
...lon/lib/forest_admin_datasource_pylon/plugins/issue_targets.rb100.0%
New file Coverage rating: A
...source_pylon/lib/forest_admin_datasource_pylon/rate_limiter.rb100.0%
New file Coverage rating: A
...pylon/lib/forest_admin_datasource_pylon/plugins/close_issue.rb100.0%
New file Coverage rating: A
...dmin_datasource_pylon/collections/account/schema_definition.rb100.0%
New file Coverage rating: A
...t_admin_datasource_pylon/collections/issue/id_lookup_reader.rb100.0%
New file Coverage rating: A
...source_pylon/plugins/create_issue_with_notification/payload.rb100.0%
New file Coverage rating: A
...rest_admin_datasource_pylon/collections/account/api_filters.rb100.0%
New file Coverage rating: A
.../forest_admin_datasource_pylon/plugins/close_issue/messages.rb100.0%
New file Coverage rating: A
...min_datasource_pylon/plugins/create_issue_with_notification.rb100.0%
New file Coverage rating: A
...asource_pylon/lib/forest_admin_datasource_pylon/issue_enums.rb100.0%
New file Coverage rating: A
...n_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb100.0%
New file Coverage rating: A
...ource_pylon/lib/forest_admin_datasource_pylon/configuration.rb100.0%
New file Coverage rating: A
...forest_admin_datasource_pylon/collections/relation_embedder.rb100.0%
New file Coverage rating: A
.../forest_admin_datasource_pylon/collections/issue/serializer.rb100.0%
New file Coverage rating: A
..._admin_datasource_pylon/collections/issue/schema_definition.rb100.0%
New file Coverage rating: A
...est_admin_datasource_pylon/collections/record_serialization.rb100.0%
New file Coverage rating: A
...orest_admin_datasource_pylon/collections/account/serializer.rb100.0%
New file Coverage rating: A
...forest_admin_datasource_pylon/collections/issue/api_filters.rb100.0%
New file Coverage rating: A
...e_pylon/lib/forest_admin_datasource_pylon/collections/issue.rb100.0%
New file Coverage rating: A
...pylon/lib/forest_admin_datasource_pylon/collections/account.rb100.0%
New file Coverage rating: A
.../lib/forest_admin_datasource_pylon/pagination/cursor_walker.rb100.0%
New file Coverage rating: A
...dmin_datasource_pylon/collections/contact/schema_definition.rb100.0%
New file Coverage rating: A
...rest_admin_datasource_pylon/query/condition_tree_translator.rb100.0%
New file Coverage rating: A
...ource_pylon/lib/forest_admin_datasource_pylon/client/writes.rb100.0%
New file Coverage rating: A
...st_admin_datasource_pylon/schema/custom_fields_introspector.rb100.0%
New file Coverage rating: A
...source_pylon/lib/forest_admin_datasource_pylon/retry_policy.rb100.0%
New file Coverage rating: A
...datasource_pylon/lib/forest_admin_datasource_pylon/throttle.rb100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

* feat(datasource): proactive throttling for the pylon datasource (EXT-13)

Space requests out inside each endpoint's documented budget instead of
discovering the quota as a 429.

RateLimits holds the per-endpoint figures read off the Pylon API reference,
one window per endpoint since Pylon meters per endpoint rather than per
token. RateLimiter keeps a sliding window per bucket, reserves the slot a
request will take under a mutex and waits outside it, so concurrent callers
spread over distinct slots rather than waking onto the same one. Throttle
is the Faraday middleware, placed inside `retry` so a replay waits for a
slot like a first attempt.

The wait is bounded (5s): past that the window is saturated by more than
this agent's own traffic, so the request goes out and the 429 retry stays
the backstop. Queueing behind a full window would trade a retry the client
already handles for a request the operator watches spin.

Also corrects the rate-limit figures the comments and one operator-facing
message asserted. They were 4x to 15x below what Pylon documents: the
search endpoints grant 120 requests a minute rather than 20, the record
reads 300 rather than 60, and no endpoint this gem touches sits at the 10
a minute the story assumed. The caps built on those figures are unchanged
and re-justified on what actually bounds them - sequential round-trips and
unbounded payloads, not the quota.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on (EXT-17) (#375)

* refactor(pylon): extract the faraday connection builder

Everything but the timeouts is the same on every connection the client
needs, so the builder takes those and holds the rest -- middleware order
and the shared limiter included -- in one place.

No behaviour change: the single connection is now its memoized call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both conflicts are the sort decorator cherry-pick 837988d, whose
content landed on main as a04900d (#368). main's side is taken on
each, so the two files leave this diff entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
christophebrun-forest and others added 3 commits August 26, 2026 17:06
A Number filter the agent cast to Infinity or NaN reached `to_i`, which
raises, and then the JSON encoder, which raises too: a 500 either way, on
a value the operator typed. It is refused with a 400 naming the field.

An action reading its issue ids off a column deduplicates them: that
column is not a key, so two selected records could name the same issue,
which was then written twice and counted twice in what the action
reported back.

`collect_pages` stops on a cursor it has already followed, not only on
one that did not move: a cursor cycling over three pages walked them
again until the page cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MAX_ID_LOOKUPS bounded a selection rather than a page: the ids were
truncated to the first 20 before the window was applied, so an offset
at or past 20 answered an empty page from records that exist, and a
wider selection lost its tail with nothing but a server-side log to say
so.

The window is now taken off the ids before any of them is read, which
also spends one request per record the caller asked to see rather than
one per record in the selection. A residual condition takes that away,
which records the window holds being known only once they are all read,
so past the cap that one selection is refused rather than answered with
a fraction of itself.

Reading by id moves to its own mixin, the collection having reached the
class-length cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A scope that is neither a symbol nor a string raised NoMethodError from
`to_sym`, hiding the unknown-scope error that names what was passed.

An email template title is both the enum value and the key its content
is looked up by, so it has to name one template: a duplicate made the
first unreachable and sent the other one's content under its name, and a
template titled like the sentinel of the "pick none" option could never
be picked at all. Both are configuration, so both are refused at
registration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# they are all read — so past the cap the selection is refused rather
# than answered with a fraction of itself.
def records_by_id(caller, lookup, filter)
return fetch_by_ids(page_of_ids(lookup.ids, filter)) if lookup.residual.nil?

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 issue/id_lookup_reader.rb:29

A missing or mismatched ID consumes a slot in the requested page, so records_by_id can return fewer rows and hide later reachable IDs. page_of_ids must be applied after fetch_by_ids resolves the IDs, not before the reads.

-          return fetch_by_ids(page_of_ids(lookup.ids, filter)) if lookup.residual.nil?
+          return page_window(fetch_by_ids(lookup.ids), filter) if lookup.residual.nil?
🚀 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/collections/issue/id_lookup_reader.rb around line 29:

A missing or mismatched ID consumes a slot in the requested page, so `records_by_id` can return fewer rows and hide later reachable IDs. `page_of_ids` must be applied after `fetch_by_ids` resolves the IDs, not before the reads.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is the behaviour a93474b deliberately replaced, so the suggested diff would restore the bug it fixed.

page_window(fetch_by_ids(lookup.ids), filter) reads the whole selection to then drop all but a window of it — one request per record nobody asked to see — and, because the selection is capped at MAX_ID_LOOKUPS before that, answers any offset at or past 20 with an empty page. That is the finding in the thread on issue.rb:139 from the previous pass.

The trade is a real one and it is stated in the code: the window is over the ids, so an id that no longer resolves leaves a gap rather than pulling the next one in behind it. Closing that gap costs a request per record outside the page to discover, and dangling ids in an id IN (...) selection are the exception — Forest builds those lists from ids it has just read. A spec pins each half of the behaviour.

christophebrun-forest and others added 2 commits August 26, 2026 17:11
`project` served the whole record when the projection named no column of
the collection, so a row asked for as `account:name` alone came back
carrying every native column too. The relation is embedded onto the row
afterwards; what `project` owns is the columns, and a projection naming
none asks for none. Only a nil projection still means the record as it
is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pylon exposes no count endpoint and no total, so no collection ever
passed `countable: true`: the cursor-backed ones refuse `aggregate`
outright rather than count the pages they walked, and the two holding
their whole dataset are not advertised as countable either. The flag was
carried by the constructor and exercised by one spec testing the flag
itself.

Recorded on EXT-7, whose scope still lists Count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
christophebrun-forest and others added 2 commits August 26, 2026 17:16
EXT-17 bounded each boot-time introspection per request, not the three
of them together: a Pylon that hangs cost the full boot bound once per
object type, so a Rails boot could wait around a minute where the
ticket promised twenty seconds.

The first failure now stands for the rest, which it does in practice: a
Pylon that is down, or a token missing the permission, fails the two
that follow the same way. `fetch_custom_fields` degrades to nil rather
than to an empty list so the introspector can tell that apart from an
organization that simply defined no custom field, which says nothing
about the next object type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Last open item of EXT-13. Covers configuration, the five collections and
what each one can be written through, the action plugins, and the two
things an operator needs to know before deploying: what Pylon cannot do
and how this refuses it rather than answering something that looks
right, and how the per-endpoint throttling behaves under saturation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@christophebrun-forest
christophebrun-forest marked this pull request as ready for review August 27, 2026 09:33
christophebrun-forest and others added 5 commits August 27, 2026 15:14
cc01551 made `project` answer a projection naming no column of the
collection with no column, which is right for a projection naming only
relations and wrong for an empty one: the second asks for nothing rather
than for relations alone, and means the record as it is.

`ActionContext#get_records` passes an empty Projection on every action,
whatever fields it was handed, so the emptied row cost the plugins the
record they act on. `Utils::Record.primary_keys` raises on it, and
`IssueTargets` rescues the raise into "no issue selected": CloseIssue
registered on a Pylon collection closed nothing, on every run, silently.
The Zendesk base collection, this package's blueprint, keeps the same
guard.

A spec now pins the empty projection, which no spec covered - which is
how 100% line coverage held while the path was broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`show_internal_note` decides whether the form carries the checkbox, and
nothing decided whether the value was honoured: `make_form_data` copies
every key the request sent into the form values, matched against a field
of the form or not, so a submitted "Send as internal note" reached
`Payload.destination_for` on an action registered without it.

The cost is the notification the plugin exists to send: the issue is
created internal and the requester is never contacted, on an action whose
configuration says they should be. A hidden field is not an absent one,
so the value is neutralised where it is read rather than trusted.

The label is now one constant shared by the three places that spell it -
the field, the payload, and the neutralisation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A select whose options were all removed falls back to String so the
column still shows what it holds, and the fallback carried the writable
flag Pylon gave the field. Pylon writes a select as the slug of one of
its options, so what that offered the operator was a free-text editor
whose every value the endpoint refuses.

Read-only there whatever Pylon says of the field, which is the invariant
`writable_definition?` states twenty lines below: this datasource
advertises nothing an endpoint would refuse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two properties an operator granting the action has no way to read off
the code. The state is written through the client rather than through
PylonIssue, because the action is registered on the host collection, so
a Forest scope or segment on PylonIssue does not bound what it closes.
And the batch is one request per selected record with no cap, so a wide
bulk selection is a long run of sequential writes the request may time
out on, leaving what was closed closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file was left at 1.36.2, where every other package sits at 1.39.4:
main has released since this branch was cut, and pylon's version.rb is
not on main for the release commit to have carried along.

Cosmetic - the release sed rewrites all fourteen either way, and the
format it matches on was already right. Aligned so the drift is not
mistaken later for the silent no-op CLAUDE.md warns about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qltysh

qltysh Bot commented Aug 27, 2026

Copy link
Copy Markdown

41 new issues

Tool Category Rule Count
qlty Structure Function with many parameters (count = 4): search_issues 23
qlty Structure Function with high complexity (count = 10): collect_pages 16
qlty Duplication Found 17 lines of identical code in 4 locations (mass = 76) 1
qlty Structure High total complexity (count = 57) 1

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 4 locations (mass = 76) [qlty:identical-code]


# POST /issues/search accepts an empty body and then returns the most recent
# issues, ordered by `created_at` descending.
def search_issues(limit:, cursor: nil, filter: nil, search_text: nil)

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 = 4): search_issues [qlty:function-parameters]

best_effort("fetch_issue_messages(#{issue_id})", default: nil) { must_succeed(path) { collect_pages(path) } }
end

def search_accounts(limit:, cursor: nil, filter: nil, search_text: nil)

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 = 4): search_accounts [qlty:function-parameters]

fetch_resource('accounts', id)
end

def search_contacts(limit:, cursor: nil, filter: nil, search_text: nil)

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 = 4): search_contacts [qlty:function-parameters]


private

def search_resource(path, limit:, cursor: nil, filter: nil, search_text: nil)

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): search_resource [qlty:function-parameters]

[:failed, "#{e.class}: #{e.message}"]
end

def success_message(issue, values, opts, writeback)

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 = 4): success_message [qlty:function-parameters]

"[forest_admin_datasource_pylon] requester_email_default resolver raised: #{e.class}: #{e.message}"
)
nil
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.

Function with high complexity (count = 6): requester_default [qlty:function-complexity]

next '' if value.nil?

escape_html ? CGI.escapeHTML(value.to_s) : value.to_s
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.

Function with high complexity (count = 5): interpolate [qlty:function-complexity]

"[forest_admin_datasource_pylon] failed to resolve the issues to act on #{source}: " \
"#{e.class}: #{e.message}"
)
[]

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 high complexity (count = 7): resolve_issue_ids [qlty:function-complexity]


attr_reader :max_wait, :window

def initialize(limits: RateLimits, window: WINDOW, max_wait: DEFAULT_MAX_WAIT, clock: nil, sleeper: nil)

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]

christophebrun-forest and others added 3 commits August 27, 2026 16:51
An `and` names the records all of its conditions name, so two `id` leaves
name their intersection. Both paths kept only the first and left the second
to be applied later, which put the lookup cap around the wider set: a
selection of 25 ids narrowed to 3 was refused over the 25 it named, and the
25 were read one request each before the 22 were dropped.

extract_id_lookup now partitions the id leaves out of the branch and
intersects them, so nothing about the residual changes and disjoint sets
name no record without a request. filtered_ids counts the same intersection,
that count being what the write refusal is worded around.

Confined to PylonIssue on the read side: cursor collections only take the
short-circuit on a bare leaf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
resolve_issue_ids rescued StandardError and answered an empty list, which
the action reports as "No Pylon issue selected." That swallowed the
datasource's own refusals: a bulk CloseIssue on PylonIssue, with a scope or
segment set and more than MAX_ID_LOOKUPS records selected, raises inside
list and the operator was told nothing was selected about a selection they
can see they made. A Pylon outage read the same way.

A ValidationError now travels, the agent surfacing its message as written.
Everything else still degrades: a renamed column, or a record the scope
hides, is still "no issue selected" rather than a stack trace in the panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The caveat said a scope on PylonIssue does not bound what the action
closes, on the grounds that the state is written through the client. The
ids are resolved first, through the collection the action sits on, and the
agent intersects the operator's scope into the filter that reads them: so
mounted on PylonIssue, the scope bounds exactly what it closes. What the
caveat describes is the host-collection form, where the ids come off a
column and that column is the authority.

Also names the discrepancy the uncapped batch has with the collections
themselves, which refuse a filter-driven write past MAX_WRITE_REQUESTS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant