Skip to content

feat(datasource intercom): read-only Intercom datasource, phase 1 - #387

Open
christophebrun-forest wants to merge 41 commits into
mainfrom
feat/datasource-intercom
Open

christophebrun-forest wants to merge 41 commits into
mainfrom
feat/datasource-intercom

Conversation

@christophebrun-forest

@christophebrun-forest christophebrun-forest commented Sep 8, 2026 •

Copy link
Copy Markdown
Member

Integration branch of phase 1 of PRD-1111 — four lots already reviewed and merged here, now going to main together:

Lot PR Ticket
1 — foundation, tickets and conversations #383 PRD-1112
2 — search filters and per-endpoint operator tables #384 PRD-1118
2.5 — relations of the reference tier #385 PRD-1145
4 — contacts, companies, promotion of the relations #386 PRD-1120

A new gem, forest_admin_datasource_intercom, publishing nine read-only collections over the Intercom REST API. Everything outside those four lots — writes on contacts and companies (lot 4b, PRD-1152), actions, tags and segments (lot 5) — is out of scope and refused by name rather than half-served.

The rule the whole package is built on

A page that looks filtered, sorted, counted or resolved without being so is the one answer this datasource must not give. Every limit of the API is either translated exactly or refused with a message naming what to change, before the request leaves the process:

  • a condition a collection cannot express is refused, naming the lot or the endpoint restriction behind it;
  • a group-by is refused rather than computed over the pages a walk happened to collect;
  • a count over a set of ids larger than a bulk read fetches is refused rather than answered with the number the truncation left;
  • a relation whose fan-out exceeds what the target resolves is refused rather than answered for the first slice of a page and left nil for the rest;
  • a pages.next whose cursor cannot be read is refused rather than taken for the last page;
  • a nesting depth past 2 or a group past 15 conditions is refused with a message naming what to simplify — Intercom answers a 400 naming neither;
  • a projection reaching through two relations is refused, rather than resolved for the first hop and quietly missing the column the second named;
  • a date filter that names no day — a time of day on its own, a month with no date — is refused rather than completed from the server's clock, which would make the answer depend on when the request ran;
  • every route out of a pagination walk that is short of what was asked for is logged, naming the window it stopped in: the two caps, and the two defensive stops Intercom does not trigger today.

The single thing that cannot be refused is a sort on the endpoints that ignore one: Intercom accepts it and drops it silently (measured), so those columns are not sortable and a requested order is reported in the log. POST /contacts/search is the only endpoint that honours a sort, and the only collection with sortable columns — everywhere else, and on its own read-by-id route, an order asked for is reported rather than dropped.

The collections

Collection Endpoint Pagination
IntercomConversation GET /conversations, POST /conversations/search cursor
IntercomTicket POST /tickets/search, GET /tickets/{id} cursor
IntercomContact GET /contacts, POST /contacts/search, GET /companies/{id}/contacts cursor + id bulk read
IntercomCompany POST /companies/list offset
IntercomAdmin, IntercomTeam, IntercomTicketType, IntercomTicketState one call each read whole
IntercomTeamMembership synthesized from GET /teams read whole

Nineteen relations are declared across the set — assignee, team, state, type, contact, company, owner, plus the two directions of the team membership — and a condition through a relation is resolved by the target itself and rewritten into a foreign-key condition, or refused by name where the endpoint filters no such key.

What a relation costs differs per target, and the README carries the table: the reference tier resolves a whole page for one request, IntercomContact for one request per hundred distinct contacts, and IntercomCompany for one request per distinct account — Intercom has no bulk read for a company. That last one is therefore the only relation with a ceiling: past MAX_RELATION_READS distinct accounts on a single read it is refused by name. Every page size a list view offers sits under it; an export, which batches a thousand rows, does not, and has to leave the column out.

The operator table is data, and it says where each row comes from

The table is a committed YAML file, one row per column per endpoint, each carrying its provenance (measured / spec), and every filter_operators in the package is derived from it — no collection writes one by hand, so a column cannot advertise a filter the translator would refuse. Every row is validated when the file loads, the script that rewrites it being the reason: an unknown operator or type, a column filed as both filterable and refused, and operators that publish nothing for the column's type all fail at boot rather than producing a schema nobody can explain. That is the mechanism, and it holds whatever the rows say.

What the rows say today is mostly spec: 19 of 85 are measured, and no endpoint has been probed end to end — all three carry measured_at: null. bin/probe_search_fields is what turns a candidate into a measurement, one search per (field, operator) cell against a real workspace, and running it is the first thing to do on the customer's. The README ranks the rows worth watching; the top of that list is admin_assignee_id / team_assignee_id, typed string here where Intercom documents Integer — they carry the filter an ops team reaches for first.

Several measured points contradict Intercom's own documentation, and are recorded as such:

  • date filters are truncated in UTC, not in the workspace timezone — which is what made today return nothing, since the two bounds the toolkit rewrites it into read as "from tomorrow" and "before today";
  • x-ratelimit-limit is 1667 and describes the 10-second window, not the documented 10 000 a minute — the limiter paces on the instantaneous rate from the response headers;
  • /tickets/search refuses company_id with invalid_field although every ticket carries one;
  • per_page=200 is refused with invalid_per_page instead of being clamped;
  • a ticket carries no statistics block, so closed_at and last_responder are derived from the parts, which arrive complete in the search response.

Privacy

A conversation body is raw personal data. Nothing logs one: logs carry operations, counts, statuses and Intercom's request id. A response that fails to parse is named rather than quoted — a JSON parser opens its message with the characters it choked on, and on a 200 those are the payload. display_as=plaintext on every conversation read, and the regional host is a first-class configuration parameter.

Outside the package

Two changes in forest_admin_datasource_toolkit, both in Utils::Collection and both needed by the relation traversal:

  • get_field_schema raises ValidationError instead of ForestException on an unknown column, an unknown relation or a relation of the wrong type — each names a field the request asked for, which is a 400 the caller can read, not a 500 saying the agent broke. Nothing stops being caught: ValidationError descends from ForestException, so every rescue ForestAdminDatasourceToolkit::Exceptions::ForestException in the repo still catches these three. The observable change is the status — Http::ErrorTranslator maps a toolkit ValidationError onto 400 — and the toolkit, customizer and agent suites are green on it;
  • list_relation compacts a related list: a through row whose target the foreign collection no longer answers yielded a nil where every consumer down to the JSON:API serializer reads a row by key.

aggregate_relation counting through those same nil targets is still open, and is mergeable on main independently.

Verification

  • 560 examples, 0 failures, 100% line coverage on the package;
  • bundle exec rubocop clean across the repository, 910 files;
  • HTTP stubbed with WebMock, every fixture hand-written from the OpenAPI 2.16 specification, none captured from a workspace;
  • the suite passes identically under UTC, Europe/Paris, America/New_York, Asia/Tokyo and Pacific/Auckland;
  • the package is registered in the five places a new gem has to be: version.rb format, gemspec MFA opt-out, .rubocop.yml, .releaserc.js, and both lists of build.yml.

Known, and documented rather than discovered

  • A relation reads its target undecorated: a permission scope or segment on the target does not narrow what a relation resolves — the behaviour of a native datasource joining a table, written down in the README.
  • The related list of an account is refused as soon as a permission scope or a segment is defined on IntercomContact. GET /companies/{id}/contacts returns the account's contacts whole and narrows nothing, and /contacts/search filters no company field, so there is no request that takes both halves. The refusal names the condition it could not carry alongside the account. Resolving it properly means reading the account's contact ids first and handing id IN [...] plus the rest of the tree to /contacts/search, which that endpoint does answer — and which is not in this lot.
  • A row whose foreign key is null matches no relation filter, negated ones included: a ticket with no assignee is not "assigned to someone other than Marie".
  • Ticket attributes are display-only (R7): an attribute is filtered through an id that differs per ticket type, so a union column has no single id to translate to. One collection per ticket type stays available if the customer says the trade is worth it — the ids are already kept per type.
  • The company_id column of a contact names the first of its accounts, and the company relation resolves that same one, while company_id equals X returns every contact of X — so an account's related list can show a contact whose company points elsewhere. The column is the payload's reading, the filter the account endpoint's.

The README carries the whole of it: collections, filtering per column with one reason per refusal, relations and what each costs, pagination tiers, rate limiting and configuration.

🤖 Generated with Claude Code

Note

Add read-only Intercom datasource (phase 1): collections, client, and query translation

  • Adds a new forest_admin_datasource_intercom package exposing read-only Intercom collections: Admin, Team, TeamMembership, TicketType, TicketState, Contact, Company, Conversation, and Ticket, registered in datasource.rb
  • Adds pagination bases (CursorCollection, OffsetCollection, FetchAllCollection) and a CursorWalker with caps of 50 pages and 7,500 records per walk
  • Adds an HTTP client with rate limiting, retries, reference caching, and per-page read deduplication, plus filter/condition-tree translation to Intercom search queries with operator limits (max depth 2, max 15 values per list)
  • Changes the toolkit's collection validators to raise ValidationError instead of ForestException for missing fields/relations, and drops nil through-relation targets in collection.rb
  • Behavioral Change: toolkit consumers previously rescuing ForestException for invalid collection fields must now rescue ValidationError; free-text search and unsupported aggregations on tickets/companies raise UnsupportedOperatorError

Macroscope summarized 5823c4b.

PRD-1112, first lot of the Intercom datasource. Six read-only
collections: conversations and tickets on the cursor tier, admins,
teams, ticket types and ticket states read whole.

Rows, record details, exact counts and the conversation thread work.
Server-side filtering (lot 2), writes and actions (lot 3), contacts
and companies (lot 4) are refused with a message naming the lot that
will answer them, rather than approximated.

254 specs, 100% line coverage, rubocop clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ables (lot 2) (#384)

* fix(intercom): page id lookups and report explicit sorts

Two findings from the lot 1 review, both places where a cursor
collection answers something other than what it was asked for.

An `id in [...]` read returned every record it fetched whatever
page was asked for, so page 1 and page 2 of a related-record list
rendered the same rows. The window is now cut out of the ids
before they are read, which also spares the requests the
discarded records cost: Intercom reads them one request each.

`default_pk_sort?` read a symbol-keyed `false` as an absent key,
so an explicit `?sort=-id` was taken for the ascending default
the agent injects, and the warning about Intercom silently
ignoring a sort never fired.

Refs PRD-1118

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

* feat(datasource): intercom relations of the reference tier

Declares the relations between the collections this datasource already
serves, and answers them (lot 2.5, PRD-1145).

Publishing a many-to-one is not free: the agent marks it filterable as
soon as any column of its target is, so the interface offers
`assignee:name` the moment the relation exists. The traversal is
therefore the bulk of this change, not the declarations.

* eight many-to-one relations towards Admin, Team, TicketState and
  TicketType, whose targets all shipped with lot 1;
* IntercomTeamMembership, synthesized from GET /teams: Intercom carries
  the membership on both sides and exposes no resource for the pair,
  while a many-to-many needs a collection to travel through. Read-only,
  Intercom writing none;
* admin_names and team_names replace the arrays of ids on Team and
  Admin, read only when a projection asks for them;
* one label per object on a ticket: state_category and
  state_external_label are dropped, both being a hop away on the state
  relation and neither having ever been filterable;
* a projection through a relation reads its target once per page; a
  condition through one is resolved by the target, exactly, and becomes
  a condition on the foreign key. Over fifteen matching ids, or on a key
  the endpoint does not filter, it is refused by name rather than
  approximated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he relations (lot 4) (#386)

* feat(intercom): contacts, companies and contact relations

Publishes the two collections phase 2 is walked through, and turns
the denormalized contact columns of lot 1 into a relation now that
their target exists.

Contacts read through the one endpoint of the API that sorts, so the
measured table gained a sortable flag and the cursor tier a
server-side order; the match-all predicate Tickets already sent is
what routes a list view asking for an order through the search.

Companies read through the third pagination tier, by offset, which is
the one place the window a list view asks for maps onto what Intercom
takes. In exchange it is looked up rather than searched, and anything
past the two published lookups is refused by name.

Contact and company custom attributes are typed from
GET /data_attributes and carry api_writable for the lot that writes,
though every column here is read-only.

Resolving a relation condition is now bounded on the cursor tier: the
target is read one record past what a group may hold, rather than
walking a whole collection to refuse the fan-out afterwards.

contact_email and contact_ids give way to contact_id and the contact
relation, one readable label plus navigation, which is the rule lot
2.5 set for the ticket labels. No saved view can rest on them: both
were already refused server-side.

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

qltysh Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

41 new issues

Tool Category Rule Count
qlty Structure Function with high complexity (count = 6): print_diff 19
qlty Structure Function with many parameters (count = 6): list_page 19
qlty Duplication Found 17 lines of identical code in 5 locations (mass = 76) 1
qlty Structure High total complexity (count = 89) 1
qlty Structure Function with many returns (count = 4): entry_for 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]

end
end
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.

High total complexity (count = 67) [qlty:file-complexity]

# `list_key` is the key the endpoint puts its records under, and it is not
# `data` everywhere: `/tickets/search` answers under `tickets` (measured), so
# a caller names its own and `data` stays the fallback.
def list_page(path, per_page:, starting_after: nil, params: {}, list_key: 'data', boot: false)

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

# collections that read them never send one, and the parameter is here for
# the one that does. `{ field:, ascending: }`, translated to Intercom's own
# spelling on the way out.
def search_page(path, query:, per_page:, starting_after: nil, list_key: 'data', params: {}, sort: 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 = 7): search_page [qlty:function-parameters]

return [0, nil] if wait <= 0
return [0, first_warning? ? wait : nil] if wait > @max_wait

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

@limit = limit if limit
return if remaining.nil?

@remaining = new_window || @remaining.nil? ? remaining : [remaining, @remaining].min

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): record [qlty:function-complexity]

add(union, name, column, definition)
end

def add(union, name, column, definition)

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

Comment thread packages/forest_admin_datasource_intercom/Gemfile
Comment thread packages/forest_admin_datasource_intercom/README.md
Comment thread packages/forest_admin_datasource_intercom/README.md Outdated
Review pass on PR #387.

Correctness

* a relation read slices the fan-out by what the target resolves in
  one read, and refuses past what it resolves at all, instead of
  resolving the first 25 rows of a page and leaving the rest nil;
* the contacts of an account with a scope or a segment filtered
  alongside are refused with a message naming that condition, rather
  than with the table reason telling the operator to read the
  account's contacts, which is what they asked for;
* the `id in [...]` route reports the order it cannot apply, refuses
  a count larger than a bulk read fetches, and deduplicates its ids;
* the cursor walk logs the two defensive stops it took silently;
* a date filter naming no day, a time of day or a month, is refused
  rather than completed from the clock of whichever host ran it;
* the offset tier bounds a windowed read by a record budget instead
  of by the page cap meant for a read with no window of its own;
* a projection reaching through two relations is refused by name,
  like the filter that reaches that deep.

Consistency

* the four enrichment hooks read the projection through
  `column_asked?`, so an all-columns read fills the columns it
  publishes;
* `search_fields.yml` fails at load on a column filed as both
  filterable and refused, and on operators that publish nothing for
  its type, which is what its own docstring promised;
* `active_support` and `active_support/time` are required rather
  than relied on through an incidental require of the toolkit;
* a base_url carrying credentials is masked in every `inspect`, and
  a cleartext one is reported as carrying the bearer in clear;
* `RecordsById` and `AttributeNaming` hold what the paginated tiers
  and the two introspectors were duplicating, messages included.

Docs: nine collections rather than eleven, 19 of 85 table rows
measured, free-text search on contacts too, what each relation
really costs, and every limit above.

560 examples, 0 failures, 100% line coverage; rubocop clean on 912
files; toolkit, customizer and agent suites green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
!limit.nil? && count > limit
end

def read_targets(caller, collection, target, ids, wanted)

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

# caller: a page that looks like the whole answer and is not is the
# failure this datasource exists to avoid, so there is no route out of
# this walk that is short and quiet.
def cut_short?(page, seen_cursors, pages, records:, window:)

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

Second review pass on PR #387. Four defects, each one a place where
the package promised something it did not do.

* a lookup naming no record is an empty page rather than a 500.
  Intercom answers `GET /companies?name=` with a 404 where a search
  endpoint would answer an empty list, and nothing read it: an
  operator typing an account name that matches nothing broke the page
  instead of seeing no row. Read the way a record read by its id
  already was, and only a 404 -- anything else still raises. The
  related list of an account Intercom no longer answers for reads the
  same way;

* the pinned API version is checked at boot. `Client#me` reads the
  header Intercom echoes the served version in, and nothing called
  it: the check the configuration claimed ran was code that never
  ran, while a workspace serving another version answers payloads of
  another shape. Called from the datasource on the boot connection,
  degrading to a warning like the three attribute reads;

* the table names every column of the collections it answers for.
  Seven columns had no row at all -- `state_id` among them, which the
  README ranks second on the list to probe -- so they fell back on
  the generic "takes no filter on it", the one refusal an operator
  cannot act on; and `state_external_label` outlived by two lots the
  column it refused. A spec now reads the schema against the table in
  both directions, which is what the reasons are worth;

* the probe ships with the gem. `bin/` is excluded from `spec.files`,
  so the tool the README makes the first step against a customer's
  workspace reached nobody who had not cloned the repository. It
  moves to `exe/forest_admin_intercom_probe` -- and with it its
  guard, RubyGems loading an executable from a stub of its own, where
  `$PROGRAM_NAME == __FILE__` would have shipped a command that exits
  without doing anything.

Consistency

* `ContactIdentity` reads the Contacts path off the table rather than
  writing it a second time;
* the account-contacts route reports an order it cannot apply with
  the route as the reason and not the column: this is the one
  collection Intercom does sort;
* the attribute families of the table stop being data nothing reads.
  `Endpoint#attribute_refusal` is validated at load and read by the
  translator, so a scope filtering on a workspace attribute is
  refused with the R7 arbitration rather than with the message for a
  column nobody declared -- those columns being named by the
  workspace, no row can name them;
* the README figures for the measured rows are derived from the file
  by a spec: 18 of 89, and they cannot drift from it again.

579 examples, 0 failures, 100% line coverage; rubocop clean on 912
files; toolkit, agent and customizer suites green.

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

print_operators(field, column, result[:operators])
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): print_diff [qlty:function-complexity]

MAX_DEPTH = 2
MAX_GROUP_SIZE = 15

def self.call(condition_tree, endpoint:, collection:, timezone: nil, attribute_columns: [])

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

# which no row of the table can name: they are discovered at boot. They
# share one refusal, carried by the endpoint, and passing them here is
# what lets it be read instead of the generic message.
def initialize(endpoint:, collection:, timezone: nil, attribute_columns: [])

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

POST /companies/list paginates by offset and advertises its next page by
number, never by cursor. Routing it through the shared page builder made
next_cursor refuse that shape, so both the list and the count answered
500. The offset tier now builds its page without reading pages.next,
which it never used, and the company fixtures carry the advertisement the
real endpoint sends on every page but the last.

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

* perf(datasource): cut a page of Intercom tickets from seven requests to two

Intercom joins nothing, so a ticket list projecting five relations was seven
sequential round trips: the search itself, the contacts read twice over, and
the four workspace lists re-read on every page.

Three changes, none of which serves a record from a store:

* the reference lists -- teammates, teams, ticket types, ticket states -- are
  held for reference_cache_ttl (60s, 0 to disable). They are what four of a
  ticket's five relations resolve through and they change a few times a year;
  what the window trades is bounded staleness, and nothing else is cached.

* the reads issued while one page is built are deduplicated for the length of
  that call and no longer. The contacts of a page were asked for twice, once
  for the contact_name column and once for the contact relation, from two
  callers that cannot see each other.

* connections are reused. Seven requests to one host meant seven TLS
  handshakes on Faraday's default adapter; the client now builds on
  faraday-net_http_persistent, and falls back with a warning where it cannot
  be loaded rather than failing to boot.

Measured in ticket_list_budget_spec: 7 requests before, 5 on the first page
after a boot, 2 on every page after it.

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

entry
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): read [qlty:function-complexity]

refuse_adapter!(adapter) unless name.respond_to?(:to_sym)
refuse_adapter!(adapter) unless options.nil? || namable_hash?(options)

options.nil? ? name.to_sym : [name.to_sym, options.to_h { |key, value| [key.to_sym, value] }]

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): normalize_adapter [qlty:function-complexity]

Pylon landed on main and registered itself in the three files a package
registers in, which is where this branch registers Intercom. The conflict was
those three and nothing else.

Both entries kept in all of them: the release sed, the gem push and the git
assets of .releaserc.js, the test matrix and the coverage file list of
build.yml, and the three cop excludes of .rubocop.yml. Verified by parsing
each file back rather than by reading the diff, since a lost entry here fails
silently: the sed no-ops and the gem is never published, with nothing red in
CI to say so.

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

qltysh Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Qlty


Coverage Impact

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

Modified Files with Diff Coverage (44)

RatingFile% DiffUncovered Line #s
New file Coverage rating: A
...rcom/lib/forest_admin_datasource_intercom/collections/admin.rb100.0%
New file Coverage rating: A
...ercom/lib/forest_admin_datasource_intercom/query/day_bounds.rb100.0%
New file Coverage rating: A
...b/forest_admin_datasource_intercom/pagination/cursor_walker.rb100.0%
New file Coverage rating: A
...rcom/lib/forest_admin_datasource_intercom/query/caller_zone.rb100.0%
New file Coverage rating: A
...t_admin_datasource_intercom/query/condition_tree_translator.rb100.0%
New file Coverage rating: A
...m/lib/forest_admin_datasource_intercom/collections/timeline.rb100.0%
New file Coverage rating: A
...intercom/lib/forest_admin_datasource_intercom/configuration.rb100.0%
New file Coverage rating: A
...st_admin_datasource_intercom/collections/contact/serializer.rb100.0%
New file Coverage rating: A
...orest_admin_datasource_intercom/collections/team_membership.rb100.0%
New file Coverage rating: A
...dmin_datasource_intercom/collections/ticket/derived_columns.rb100.0%
New file Coverage rating: A
...om/lib/forest_admin_datasource_intercom/collections/contact.rb100.0%
New file Coverage rating: A
...m/lib/forest_admin_datasource_intercom/query/operator_table.rb100.0%
New file Coverage rating: A
...admin_datasource_intercom/collections/conversation/timeline.rb100.0%
New file Coverage rating: A
...com/lib/forest_admin_datasource_intercom/query/filter_value.rb100.0%
New file Coverage rating: A
...ercom/lib/forest_admin_datasource_intercom/collections/team.rb100.0%
New file Coverage rating: A
...asource_intercom/lib/forest_admin_datasource_intercom/cache.rb100.0%
New file Coverage rating: A
...b/forest_admin_datasource_intercom/collections/conversation.rb100.0%
New file Coverage rating: A
...min_datasource_intercom/collections/conversation/serializer.rb100.0%
New file Coverage rating: A
.../lib/forest_admin_datasource_intercom/collections/relations.rb100.0%
New file Coverage rating: A
..._admin_datasource_intercom/collections/fetch_all_collection.rb100.0%
New file Coverage rating: A
...ib/forest_admin_datasource_intercom/collections/ticket_type.rb100.0%
New file Coverage rating: A
...rest_admin_datasource_intercom/exe/forest_admin_intercom_probe100.0%
New file Coverage rating: A
...st_admin_datasource_intercom/collections/company/serializer.rb100.0%
New file Coverage rating: A
...est_admin_datasource_intercom/collections/custom_attributes.rb100.0%
New file Coverage rating: A
..._intercom/lib/forest_admin_datasource_intercom/rate_limiter.rb100.0%
New file Coverage rating: A
.../forest_admin_datasource_intercom/collections/records_by_id.rb100.0%
New file Coverage rating: A
...rest_admin_datasource_intercom/collections/contact_identity.rb100.0%
New file Coverage rating: A
...ce_intercom/lib/forest_admin_datasource_intercom/datasource.rb100.0%
New file Coverage rating: A
...om/lib/forest_admin_datasource_intercom/collections/company.rb100.0%
New file Coverage rating: A
...orest_admin_datasource_intercom/collections/base_collection.rb100.0%
New file Coverage rating: A
...in_datasource_intercom/lib/forest_admin_datasource_intercom.rb100.0%
New file Coverage rating: A
...est_admin_datasource_intercom/collections/cursor_collection.rb100.0%
New file Coverage rating: A
...om/lib/forest_admin_datasource_intercom/query/search_fields.rb100.0%
New file Coverage rating: A
...source_intercom/lib/forest_admin_datasource_intercom/client.rb100.0%
New file Coverage rating: A
...est_admin_datasource_intercom/collections/offset_collection.rb100.0%
New file Coverage rating: A
...est_admin_datasource_intercom/collections/ticket/serializer.rb100.0%
New file Coverage rating: A
...b/forest_admin_datasource_intercom/collections/ticket_state.rb100.0%
New file Coverage rating: A
...com/lib/forest_admin_datasource_intercom/collections/ticket.rb100.0%
Coverage rating: A Coverage rating: A
...oolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb100.0%
New file Coverage rating: A
..._intercom/lib/forest_admin_datasource_intercom/retry_policy.rb100.0%
New file Coverage rating: A
...ib/forest_admin_datasource_intercom/schema/attribute_naming.rb100.0%
New file Coverage rating: A
...n_datasource_intercom/schema/ticket_attributes_introspector.rb100.0%
New file Coverage rating: A
...min_datasource_intercom/schema/data_attributes_introspector.rb100.0%
New file Coverage rating: A
...urce_intercom/lib/forest_admin_datasource_intercom/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.

christophebrun-forest and others added 6 commits October 28, 2026 17:42
Follows the repository relicensing in #401: the gemspec still declared GPL-3.0 and the package shipped no LICENSE file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… causes

Both re-raises in must_succeed chained the rescued error as cause, which full_message and error trackers print: a parsing error quotes the payload, and Faraday's error carries the request with its bearer token. The APIError keeps only the status and the body, so with no cause the request is no longer reachable from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elations

aggregate read relation paths such as team:name on the flat records, so every row fell into one nil group: a team leaderboard showed one blank bar holding the total. The relations the aggregation names are now nested in first, as list does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…int filters

Ticket#state, Ticket#previous_state and Contact#company were offered as filters the datasource then refused. Each many-to-one now sets is_filterable from the same check the runtime refusal uses, so the agent refuses the traversal before it reaches the datasource.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mpanies by name

Neither collection declared search, so the customizer emulated one as an equality on every String column: a ticket search came back as an empty page, a company search as an error about a filter nobody wrote. Both now declare search, tickets reach their existing refusal and the offset tier refuses one before reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 29, 2026

Copy link
Copy Markdown

PRD-1858

christophebrun-forest and others added 2 commits October 29, 2026 09:24
…arch takes

A ticket returns its category as Customer, Back-office or Tracker, while /tickets/search takes request, task and tracker. A filter on the label the column shows was sent unchanged and matched nothing. Rows of search_fields.yml can now carry a values map from what the column shows to what the search takes; a value outside it is refused by name. The fixtures used the search vocabulary in responses, which is why no spec caught it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The three tiers read a page limit of 0 like no limit at all, and walked the collection up to its caps. The toolkit's Page#apply and the cursor walker both read it as an empty window; a single page_limit helper now keeps nil and 0 apart, and no request is sent for an empty window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README still told an operator that company:name was refused with a message from this datasource; the relation is now published unfilterable and the agent refuses it first. It also says that a search on tickets or companies, global search included, comes back as a refusal. The company search refusal names id among the lookups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
collected = collect_pages(first_page: (offset / per_page) + 1, per_page: per_page,
wanted: limit && (skip + limit))

limit ? (collected[skip, limit] || []) : collected.drop(skip)

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): listed_records [qlty:function-complexity]

christophebrun-forest and others added 7 commits October 29, 2026 11:27
The overview still introduced lot 1, with filtering, contacts and companies announced for later; all three are served. It now names what is still to come.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…othing

Any APIError was recorded as an operator refusal, so a 401, a 429 after the retries, a 5xx or a timeout wrote evidence that a valid filter is refused. Only a 400 or a 422, the answers Intercom gives a search it refuses, is a measurement now; anything else stops the run before it writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rinted

redacted_url masked the credentials before the host but printed the query verbatim, so a gateway key passed as ?api_key=... reached Configuration#inspect, Client#inspect and the cleartext-url warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…allows

plan_wait counted the request before reading exhaustion, so a remaining of 1 made the request it still allowed wait for the next window. Exhaustion is now read first and only a request let through is counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts first page

A lookup answered over several pages was counted by the size of the first one. It is now counted by the total_count Intercom gave, and refused by name when a paginated answer carries none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ord budget

The budget was checked after a page was appended, so a window could come back with up to a page more than MAX_COLLECTED_RECORDS. Intercom numbers its pages by per_page, so the last page cannot be asked smaller; what it brings past the budget is cut instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ther type

Two ticket types declaring the same attribute name with different types shared one column, typed after the first: a Number column could show the other type's string. The introspector now records the ticket types that conflict and logs it, and a ticket of such a type reads the column as empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Intercom numbers its pages by `per_page`, so the last one read cannot
# be asked smaller: what it brings past the budget is cut here instead.
wanted ? records.first(MAX_COLLECTED_RECORDS) : records

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 = 11): collect_pages [qlty:function-complexity]

return same_name(entry, definition, type_id) if entry.name == name

warn_collision(name, entry.name, column)
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.

Found 2 issues:

1. Function with many returns (count = 4): entry_for [qlty:return-statements]


2. Function with high complexity (count = 5): entry_for [qlty:function-complexity]

christophebrun-forest and others added 3 commits October 29, 2026 11:41
…on column

The reference tier kept any sort clause naming a field it declares, a relation or a Json column included, and ordered by nil on every row without a word. Only sortable columns are kept now; the others are reported like a field the collection does not carry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds were named

The window of an id in [...] read is cut on the ids, and the order a sort asked for is reported as not applicable on this route, the rows coming back in the order the ids were named. /contacts/search answers in its own order, so for contacts that was not true; the records are now put back in the order of the ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Faraday turns credentials in the url into a Basic Authorization header, which the Intercom bearer token then replaced: they were dropped on every request without a word, so a credentialed gateway could never authenticate. The url is refused at boot, pointing to HTTPS_PROXY, which Faraday sends as Proxy-Authorization. The refusals naming a base_url no longer print it unredacted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ConfigurationError raised for a base_url URI cannot parse chained the URI::InvalidURIError as its cause, whose message quotes the url unredacted, credentials included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rescue URI::InvalidURIError
# Without the parser's error, whose message quotes the url unredacted.
raise ConfigurationError,
"ForestAdminDatasourceIntercom base_url is not a valid url: #{redacted_url.inspect}", cause: 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 high complexity (count = 5): validate_base_url! [qlty:function-complexity]

christophebrun-forest and others added 9 commits October 29, 2026 11:51
The gem subclasses and requires forest_admin_datasource_toolkit but only this repository's Gemfile named it, so installing the published gem on its own failed with a LoadError. Declared as Pylon and ActiveRecord do, the release keeping both versions in step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d is read by

The account's contacts relation filters IntercomContact on company_id
equal, but the column published no operator, so the customizer's
operators equivalence decorator crashed with a NoMethodError on both the
related list and its count. company_id now publishes equal, answered by
GET /companies/{id}/contacts, and a spec reads every to-many relation
through the customizer stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hows

A team's admin_ids can still name a teammate /admins no longer answers.
The related list dropped that pair while its count kept it, so a team's
teammates read N-1 rows under a counter of N. Memberships are now read
against /admins, through the reference cache IntercomAdmin already uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ling the boot

A null or non-object entry in /ticket_types, or in a type's attribute
list, raised a NoMethodError outside the APIError rescue and stopped the
agent from booting. Such an entry is now skipped and the other types
keep their attribute columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ooked up

The refusal named the first leaf of the tree, so name = 'Acme' AND
plan_name = 'Pro' blamed name, a column the message itself lists as
filterable. It now names the first condition the collection does not
publish, or the combination when each condition is filterable alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Counting a set of ids read every contact it named, up to three searches
of a hundred full payloads. Each slice is now one search asked for a
single record, and the count is the sum of their total_count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Companies, conversations and tickets read by id cost one request each,
issued one after another: up to 100 for a related list of companies, 10
for a page of conversation timelines. Client#fetch_records now reads
them concurrent_reads at a time, keeping the order of the ids, the 404
as nil, the read scope on the calling thread, and the failure of the
earliest id once every worker is joined.

concurrent_reads defaults to 1, the previous behaviour: every read in
flight holds a connection of the pool, so widening it is left to the
integrator, sized against pool_size and the server's thread count as
the README explains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on a real workspace: Intercom refuses an IN naming more than 50
values with a 400 naming neither the field nor the condition. Contacts
were read, counted and resolved 100 ids per search, so any page naming
more than 50 contacts failed. They now go 50 at a time, and an in or
not in filter of more than 50 values is refused before it is sent,
with a message naming the limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on a real workspace: past 50 values Intercom refuses the list's
length, and past 15 it refuses the composite query each value counts as
an element of. The limit holds per list, on IN and NIN alike, wherever
the list stands: an IN of 15 next to another condition, or two lists of
10 and 6 in nested groups, are accepted. Contacts are now read, counted
and resolved 15 ids per search, and a longer list is refused before it
is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
found[id] = record
scope[record_key(path, id, params)] = record if scope && record
end
ids.map { |id| found[id] }

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): fetch_records [qlty:function-complexity]


def record_key(path, id, params) = ['record', path, id, params]

def read_record(path, id, params, boot: false)

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

end
end
end.each(&:join)
outcomes

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): run_workers [qlty:function-complexity]

open_timeout: 5, timeout: 30, retry_policy: RetryPolicy.new,
rate_limiter: RateLimiter.new, boot_open_timeout: 3, boot_timeout: 10,
boot_retry_policy: RetryPolicy.boot,
reference_cache_ttl: DEFAULT_REFERENCE_CACHE_TTL, adapter: 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 = 14): initialize [qlty:function-parameters]

christophebrun-forest and others added 3 commits October 29, 2026 16:13
…op base_path

Configuration#base_path was called by nothing. The adapter fallback
could not run for the default adapter, a hard dependency of the gem, and
for a configured adapter it hid the misconfiguration behind a slower
default. An adapter the process cannot load is now refused at boot,
like every other invalid option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Collection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every comment block of eight lines or more is cut to three at most,
keeping what the code cannot say: the measured Intercom behaviours and
their figures, the workarounds, the concurrency constraints and the
ticket references. Comment lines go from 2148 to 1270; no code line
changed.

Co-Authored-By: Claude Opus 5.5 <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