Skip to content

feat(datasource-intercom): tickets and conversations, read-only (lot 1) - #383

Merged
christophebrun-forest merged 9 commits into
feat/datasource-intercomfrom
feat/prd-1112-tickets-conversations-read
Sep 1, 2026
Merged

feat(datasource-intercom): tickets and conversations, read-only (lot 1)#383
christophebrun-forest merged 9 commits into
feat/datasource-intercomfrom
feat/prd-1112-tickets-conversations-read

Conversation

@christophebrun-forest

@christophebrun-forest christophebrun-forest commented Sep 1, 2026

Copy link
Copy Markdown
Member

First lot of the Intercom datasource — PRD-1112, under the epic PRD-1111.

Targets the integration branch feat/datasource-intercom, not main: the lots of phase 1 are merged there and go to main together.

What this brings

A new packages/forest_admin_datasource_intercom gem publishing six read-only collections:

Collection Endpoint Tier
IntercomConversation GET /conversations, GET /conversations/{id} cursor
IntercomTicket POST /tickets/search, GET /tickets/{id} cursor
IntercomAdmin, IntercomTeam, IntercomTicketType, IntercomTicketState one call each 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) do not — and say so rather than pretending.

The line that runs through it

A result that looks filtered and is not is worse than an explicit refusal. Every limit of the API is either translated exactly or refused with a message naming what to change:

  • a condition a cursor collection cannot honour is refused, naming the lot that will answer it — only id equals X is served, through the record endpoint;
  • a group-by is refused rather than computed over the pages a walk collected;
  • an in-memory condition the reference tier cannot evaluate is refused rather than emptying the page;
  • a pages.next whose cursor cannot be read is refused rather than taken for the last page;
  • pagination truncation is logged, naming the window it stopped in.

The one thing that cannot be refused is a sort: Intercom accepts it and ignores it without a word (measured), so no column is sortable and a requested order is reported in the log.

Measured, not assumed

Everything below came from a real workspace — the trial one for the API semantics, then a customer workspace of 81 142 tickets — and several points contradict the documentation:

  • /tickets/search answers under tickets, /admins under admins, /teams under teams, not under data;
  • a ticket carries no statistics block: neither a closure date nor a last responder exists as a field;
  • but its parts arrive complete in the search response, so both are derived at no extra cost — closed_at from the last transition into a resolved category (matched on the ticket_state_updated prefix, since workflows close tickets through other variants), last_responder from the last comment part, notes excluded;
  • and that payload is the real cost: no field selection exists, so tickets page at 25 rather than at the 150 the API accepts;
  • x-ratelimit-limit is 1667, describing the 10-second window, not the documented 10 000 a minute — so the limiter paces on the instantaneous rate, driven by the response headers;
  • per_page=200 is refused with invalid_per_page instead of being clamped;
  • ticket dates are epoch seconds, and Intercom truncates date filters in UTC — everything is read back as ISO8601 UTC for that reason.

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.

Verification

  • 244 specs, 100% line coverage, HTTP stubbed with WebMock;
  • every fixture hand-written from the OpenAPI 2.16 specification, none captured from a workspace;
  • bundle exec rubocop clean across the repository;
  • package registered in the four places a new gem has to be: version.rb format, gemspec MFA opt-out, .rubocop.yml, .releaserc.js, and both lists of build.yml.

Before this reaches main

Not blocking this merge, but open on the lot:

  1. a run against the customer's workspace with a real token — that is what ticks the acceptance criteria, and it will confirm the conversations envelope key, which was deduced from tickets rather than measured;
  2. per_page against real response sizes, to replace the provisional 25;
  3. the enumeration of the fields /tickets/search actually filters on (company_id is refused with invalid_field — filtering tickets by account is not the given the plan assumed), which is also the table lot 2 needs.

🤖 Generated with Claude Code

Note

Add forest_admin_datasource_intercom gem with read-only tickets and conversations collections

  • Introduces a new Ruby gem exposing Intercom data as Forest Admin read-only collections: Admin, Team, TicketType, TicketState, Conversation, and Ticket
  • Adds two collection base classes: FetchAllCollection for small reference sets with in-memory filtering/sorting/pagination, and CursorCollection for cursor-paginated endpoints (conversations, tickets) with read-only, non-sortable fields and limited filter operators
  • Adds an HTTP Client backed by Faraday, with a RateLimiter driven by Intercom x-ratelimit-* headers, a Throttle middleware, and a RetryPolicy for transient failures and 429s
  • Adds CursorWalker to bridge offset/limit requests onto Intercom cursor pagination, with deduplication and configurable page/record caps that log a warning when truncation occurs
  • Adds TicketAttributesIntrospector to discover ticket-attribute columns at boot, with name sanitization, type mapping, and collision detection; API failures degrade to an empty attribute list with a warning
  • Behavioral Change: Conversation timelines are only fetched for detail views up to MAX_TIMELINE_READS; remaining rows get nil timelines with a warning. Ticket page size uses a conservative cap below Client::MAX_PER_PAGE

Macroscope summarized a51bd3d.

christophebrun-forest and others added 7 commits August 31, 2026 16:44
First PR of lot 1 (PRD-1112): the package skeleton, nothing that talks
to Intercom yet. Configuration, the Faraday client and the collections
each follow in their own PR, so this one only has to prove the package
is wired into rubocop, rspec, coverage and the release before any
behaviour rests on it.

Registered in the four places a package has to be declared, since a
missing entry breaks CI or releases silently rather than loudly:
version.rb in the exact format the release sed matches, the gemspec MFA
opt-out plus its rubocop excludes, the three spots of .releaserc.js,
and both the test matrix and the coverage file list of build.yml.

Ships the error hierarchy the rest of the lot leans on:
UnsupportedOperatorError descends from the toolkit's ValidationError so
a filter Intercom cannot express exactly answers 400 with a message the
operator can act on, and APIError carries Intercom's status and parsed
body so a smart action can surface its reason instead of an opaque
failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second PR of lot 1 (PRD-1112). Everything a request needs before
there is an endpoint to call: where it goes, which API version it
asks for, how long it waits, and how it reacts to a refusal.

The regional host is a first-class parameter rather than a constant.
api.intercom.io does route to the right region, but a workspace under
GDPR wants its requests reaching the European host and nothing else, and
a base_url also points the client at a mock server or an egress proxy.

The API version is pinned. Without the header a request follows the
workspace's own default version, which an operator can change on
Intercom's side -- and the payloads change shape under us. Intercom
echoes the version it served, so the health check compares the two and
says so when the pin was not honoured, rather than raising: running
against a version we did not ask for still beats not running.

Pacing is driven by the response headers, not by a table of budgets.
Intercom allocates its quota in 10-second windows -- the measured
x-ratelimit-limit is 1667, not the documented 10 000 a minute -- so what
matters is the instantaneous rate, and every response says what is left
of the current window and when it refills. The limiter waits that reset
out; it also counts its own requests down, since several can be in
flight before any of them answers. A reset further out than a window is
a clock disagreement rather than a window emptying, so the request goes
through and the log says why once per window.

The 429 retry stays behind the limiter as the backstop for what this
process cannot see: the workspace budget is shared with every other
private app the customer runs. Only the verbs that change nothing are
replayed, plus the 429 on any verb -- Intercom rejects it unprocessed,
where a 502 on the way back from a POST it did perform would be replayed
into a second reply on a conversation.

per_page is bounded before it is sent: Intercom answers invalid_per_page
past 150 instead of clamping, so the list view breaks rather than
shrinks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third step of lot 1 (PRD-1112). Forest sends an offset/limit window;
Intercom only hands out the page after a cursor and documents that
jumping to page N is not supported. The walker bridges the two: it
follows cursors until the window is covered, then slices it out.

The walk is capped at 50 pages of 150 records -- not for the quota,
which is generous, but for what an operator is willing to wait for and
because page 200 of a list view answers no real question. Every
truncation is logged naming the window it stopped in: a page that looks
like the whole answer and is not is the failure this datasource exists
to avoid.

Records are deduplicated by id inside the walk. Intercom documents that
a dataset modified between two paginated requests yields duplicates or
missed records, and conversations move constantly -- two rows carrying
one id is what a list view renders as two identical lines and a count
that never adds up. The missed counterpart is inherent to cursor
pagination and gets documented rather than papered over.

The client gains the page envelope: records, the cursor the next page
advertises, and the exact total_count that will feed Forest's record
counter. An advertised next page whose cursor cannot be read is refused
rather than taken for the last page -- including the url shape an older
API version serves, whose query string is read instead. A `data` that is
absent or is not a list is refused too: read as an empty page, it would
hand the collection rows built out of envelope keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourth step of lot 1 (PRD-1112). Admins, teams, ticket types and ticket
states: the collections that turn an assignee id into a teammate and a
state id into a label, which is what the conversation and ticket rows of
the next steps need to be readable at all.

Their endpoints answer whole -- no pagination parameter, no filter, no
sort -- which paradoxically makes this the most capable tier of the
datasource. Filtering, sorting, paging and counting them in memory is
exact rather than approximate, because the records in hand are every
record Intercom holds: a window cut out of them carries the rows a
server-side query would have returned. It is the one place where an
in-memory pass does not risk the thing refused everywhere else, a result
that looks filtered without being filtered, which only arises when what
one holds is a page of something larger. So these are the only countable
and groupable collections of the lot, and the cost is bandwidth.

A condition the tier cannot evaluate is refused rather than applied:
`match` answers nil for an operator with no in-memory equivalence and
`apply` reads that as "no match", so it would otherwise hand back an
empty page an operator cannot tell from a real answer. The schema
advertises no such operator -- every advertised one is re-checked against
the toolkit rather than trusted -- but a scope, a segment or a customizer
can still send one.

Two Intercom particulars, both measured rather than assumed: /admins and
/teams put their records under their own key where /ticket_types uses the
`data` envelope, so the collection names its key and `data` is the
fallback; and a team id is a string on the team and a number inside
`admin_ids`, so ids are stringified or a filter value from Forest would
never match. `fetch_all` also follows a cursor if one is advertised,
since no pagination parameter in the specification is not a promise that
a large workspace answers in one response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifth step of lot 1 (PRD-1112). The collection an ops team actually
works in: what is open, assigned to whom, since when, and what the
customer wrote.

Cursor collections get their own tier, the opposite of the reference
one in every respect: what is in hand is a page of something far
larger, so nothing may be filtered, sorted or counted in memory without
answering a fraction as if it were the whole. Three routes and no
fourth -- no condition walks the listing, `id equals X` reads the record
through its own endpoint, and anything else is refused with a message
naming the lot that will answer it. Every column ships unfilterable and
unsortable for that reason; only the primary key advertises operators,
and it is answered by the record endpoint rather than by a filter.

Counting is the exception that costs nothing: `total_count` is exact on
every response, so the record counter is one request. A group-by is
refused rather than computed over the pages a walk collected, which
would look exact while answering a fraction.

The order Intercom silently drops is reported. A sort sent to these
endpoints raises nothing and changes nothing -- measured -- so an
operator who asked for an order and did not get one learns it here or
nowhere.

The timeline opens on `source`, not on the parts: the message that
started the conversation lives there, and a thread built from the parts
alone loses exactly the one nobody opens a conversation without wanting
to read. Every entry keeps its `part_type`, an assignment and a reply
being different events. Since Intercom returns the parts only when
retrieving a single conversation, a record read gets its timeline for
free while a list view pays a request per row: bounded to ten, and the
rows past it keep a nil that reads as unknown rather than an empty
thread.

Contact identity is denormalized onto the row by one bulk read per page
rather than a lookup per row, and a failure there costs the two columns
instead of the page. It stays a pair of columns rather than a relation:
the Contacts collection arrives in lot 4, and a relation whose target is
missing is a schema the agent refuses to boot on.

Two Intercom particulars handled on the way: the records come under
`conversations` rather than the `data` envelope, so `list_page` now
takes a `list_key` like `fetch_all` does, and dates travel as epoch
seconds, read back as ISO8601 in UTC -- where Intercom stores and
truncates, and where a local rendering would hide the shift that makes
a day-granular date filter wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sixth step of lot 1 (PRD-1112), and the one the measurements on a real
workspace of 81 142 tickets reshaped.

There is no GET /tickets at all, so even an unfiltered list view goes
through POST /tickets/search with a predicate matching everything, and
its records come back under `tickets` rather than the `data` envelope.
The cursor tier grew a `read_page` hook for that: walking a page is the
base's business, which endpoint answers it is the collection's.

The page size is bounded at 25, far below the 150 the API accepts. Not
caution: a ticket carries its whole timeline in the search response and
Intercom offers no field selection, so a page of 150 would move some 23
000 part objects. The figure is provisional until measured against real
response sizes on the customer's workspace.

That same payload is what makes the two derived columns defensible. A
ticket has no `statistics` block -- measured, confirming the
specification -- so neither a closure date nor a last responder exists
as a field, but both are in the parts, which are paid for whether or not
anything asks:

  * `closed_at` and `closed_by_name` come from the last transition into
    a state of category `resolved`. Matched on the `ticket_state_updated`
    prefix rather than the `_by_admin` variant the sample showed, since
    this workspace runs workflows and a closure done by automation would
    otherwise be invisible; transitions whose target equals the previous
    state are ignored, because they exist.
  * `last_reply_at`, `last_responder_name` and `last_responder_type`
    come from the last `comment` part, notes excluded: an internal note
    is a touch, not an answer to the person waiting.

Both ship display-only, and that is not temporary: the search endpoint
filters neither and ignores a sort without reporting it, so a column
advertising either would put in the interface what the read cannot
honour. When a resolved ticket's transition fell past Intercom's
500-part ceiling the value is unknown rather than absent -- detected by
comparing the parts in hand with their total, and reported in a log
since a Date column cannot say it.

The ticket-type attributes are introspected once at boot and published
as the union of every type's, keyed by name the way the payload is. The
id each type gives the same name is kept even though nothing uses it
yet: it is what the filter translation will need, and re-reading it
would cost a second boot round trip. A token without that permission
costs the attribute columns, never the boot. An attribute whose name a
native column already carries is skipped rather than overwriting it.

The state arrives embedded as a whole object, so its labels cost
nothing, and the contact identity denormalization moved to a module both
collections now share.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seventh and last step of lot 1 (PRD-1112): the README, and the one
hardening the review of the logs turned up.

A response that fails to parse used to travel into the error message,
and a JSON parser opens its message with the characters it choked on.
On a 4xx those are Intercom's error text, which is what an operator
needs; on a 200 they are the payload -- a conversation body, most of the
time. The failure is now named rather than quoted, at both levels: the
Faraday parsing error, and a parser raising on its own, which would
otherwise have reached the catch-all whose message is the exception's.
The rest of the log review came back clean -- operations, counts,
statuses and Intercom request ids, never content.

The README documents what this datasource refuses and why, since that is
the part an operator meets first: no offset pagination, a sort accepted
and ignored, no aggregate endpoint, `per_page` refused past 150 and
bounded at 25 for tickets, no `GET /tickets` at all, and an envelope key
that is not always `data`. Then the two tiers and why they behave
differently, the derived ticket columns with the 500-part ceiling that
makes a closure date unknown rather than absent, the rate-limit windows,
the privacy rules, and the single read a boot performs.

Every figure in it was checked against the constants rather than
remembered.

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

linear-code Bot commented Sep 1, 2026

Copy link
Copy Markdown

PRD-1112

@qltysh

qltysh Bot commented Sep 1, 2026

Copy link
Copy Markdown

23 new issues

Tool Category Rule Count
qlty Structure Function with high complexity (count = 9): collect_pages 13
qlty Structure Function with many parameters (count = 6): list_page 8
qlty Duplication Found 17 lines of identical code in 4 locations (mass = 76) 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]

# `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]

# One page of a search endpoint. The query is written by the caller rather
# than translated from a Forest filter -- that translation is lot 2 -- so
# what goes on the wire is what the caller asked for.
def search_page(path, query:, per_page:, starting_after: nil, list_key: 'data')

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

# One record from its own endpoint. Raises on a 404 like on any other
# failure: what a missing record means -- a stale link, a record outside the
# token's scope, a deletion -- is the caller's to decide, not the client's.
def fetch_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): fetch_record [qlty:function-parameters]

end
end

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

cursor = page.next_cursor
end

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

Client.bounded_per_page(budget)
end

def log_truncation(offset:, limit:, pages:, collected:)

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): log_truncation [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]

@qltysh

qltysh Bot commented Sep 1, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on feat/datasource-intercom by 0.3%.

Modified Files with Diff Coverage (23)

RatingFile% DiffUncovered Line #s
New file Coverage rating: A
...rcom/lib/forest_admin_datasource_intercom/collections/admin.rb100.0%
New file Coverage rating: A
...b/forest_admin_datasource_intercom/pagination/cursor_walker.rb100.0%
New file Coverage rating: A
...intercom/lib/forest_admin_datasource_intercom/configuration.rb100.0%
New file Coverage rating: A
..._intercom/lib/forest_admin_datasource_intercom/retry_policy.rb100.0%
New file Coverage rating: A
...n_datasource_intercom/schema/ticket_attributes_introspector.rb100.0%
New file Coverage rating: A
...dmin_datasource_intercom/collections/ticket/derived_columns.rb100.0%
New file Coverage rating: A
...admin_datasource_intercom/collections/conversation/timeline.rb100.0%
New file Coverage rating: A
...ercom/lib/forest_admin_datasource_intercom/collections/team.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
..._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
...urce_intercom/lib/forest_admin_datasource_intercom/throttle.rb100.0%
New file Coverage rating: A
..._intercom/lib/forest_admin_datasource_intercom/rate_limiter.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
...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
...source_intercom/lib/forest_admin_datasource_intercom/client.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%
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.

Comment thread .releaserc.js
'( 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_graphql_hasura && gem build && gem push forest_admin_datasource_graphql_hasura-*.gem );' ,
'( cd packages/forest_admin_datasource_graphql_hasura && gem build && gem push forest_admin_datasource_graphql_hasura-*.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:49

The release can report success while forest_admin_datasource_graphql_hasura was not updated or published. Because the Hasura and Intercom commands are separated with ;, a failed Hasura sed, build, or push is overwritten by a successful Intercom command and successCmd/prepareCmd exits 0; chain each command with && (or enable fail-fast) so the failure propagates.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @.releaserc.js around line 49:

The release can report success while `forest_admin_datasource_graphql_hasura` was not updated or published. Because the Hasura and Intercom commands are separated with `;`, a failed Hasura `sed`, build, or push is overwritten by a successful Intercom command and `successCmd`/`prepareCmd` exits 0; chain each command with `&&` (or enable fail-fast) so the failure propagates.

private

def fetch_records(filter)
ids = id_lookup(filter)

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 collections/cursor_collection.rb:107

Primary-key equal/in list requests ignore filter.page, so id in [1, 2, 3] with offset: 1, limit: 1 returns all three records instead of one. The early return from fetch_records bypasses pagination; apply translate_page to the records returned by records_by_ids.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/collections/cursor_collection.rb around line 107:

Primary-key `equal`/`in` list requests ignore `filter.page`, so `id in [1, 2, 3]` with `offset: 1, limit: 1` returns all three records instead of one. The early return from `fetch_records` bypasses pagination; apply `translate_page` to the records returned by `records_by_ids`.

# reason -- together they cut every path from an object this package hands
# out to the credential.
def inspect
"#<#{self.class.name} url=#{url.inspect} api_version=#{@api_version.inspect} access_token=[FILTERED]>"

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_intercom/configuration.rb:70

Configuration#inspect exposes a proxy password whenever base_url contains URI userinfo, so logging or rendering a valid URL such as https://proxy-user:proxy-password@proxy.example/api leaks credentials despite the method's redaction boundary. Because url.inspect is interpolated verbatim, redact the URI userinfo before including the URL.

-      "#<#{self.class.name} url=#{url.inspect} api_version=#{@api_version.inspect} access_token=[FILTERED]>"
+      "#<#{self.class.name} url=#{URI.parse(url).tap { |uri| uri.userinfo = '[FILTERED]' if uri.userinfo }.to_s.inspect} api_version=#{@api_version.inspect} access_token=[FILTERED]>"
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/configuration.rb around line 70:

`Configuration#inspect` exposes a proxy password whenever `base_url` contains URI userinfo, so logging or rendering a valid URL such as `https://proxy-user:proxy-password@proxy.example/api` leaks credentials despite the method's redaction boundary. Because `url.inspect` is interpolated verbatim, redact the URI userinfo before including the URL.

Comment on lines +313 to +321
message = join_errors(parsed['errors'])
message = parsed.to_json if message.empty?

append_request_id(message[0, 500], parsed['request_id'])
end

def join_errors(errors)
Array(errors).filter_map do |error|
next error unless error.is_a?(Hash)

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_intercom/client.rb:313

Malformed JSON error bodies are copied into APIError#message, exposing conversation or other personal data to the UI and error collectors. join_errors returns non-Hash entries verbatim, and the fallback serializes any hash without usable errors; skip non-Hash entries and use a size-only summary instead of parsed.to_json.

       message = join_errors(parsed['errors'])
-      message = parsed.to_json if message.empty?
+      message = "(unreadable body, #{parsed.to_json.bytesize} bytes)" if message.empty?
 
       append_request_id(message[0, 500], parsed['request_id'])
@@
       Array(errors).filter_map do |error|
-        next error unless error.is_a?(Hash)
+        next unless error.is_a?(Hash)
 
         [error['code'], error['message']].compact.join(': ')
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/client.rb around lines 313-321:

Malformed JSON error bodies are copied into `APIError#message`, exposing conversation or other personal data to the UI and error collectors. `join_errors` returns non-Hash entries verbatim, and the fallback serializes any hash without usable errors; skip non-Hash entries and use a size-only summary instead of `parsed.to_json`.

Comment on lines +119 to +121
def blank_search?(filter)
search = filter.respond_to?(:search) ? filter.search : nil
search.nil? || search.to_s.strip.empty?

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 collections/cursor_collection.rb:119

Requests carrying a nonempty filter.search_extended return incorrect results: an id = X request returns X without applying the extended search, while a list request returns an unfiltered page instead of refusing the unsupported search. Both id_lookup and browsing? rely on blank_search?, which checks only filter.search; include filter.search_extended in that check so these requests are rejected.

 def blank_search?(filter)
-        search = filter.respond_to?(:search) ? filter.search : nil
-        search.nil? || search.to_s.strip.empty?
+        search = filter.respond_to?(:search) ? filter.search : nil
+        extended_search = filter.respond_to?(:search_extended) ? filter.search_extended : nil
+        [search, extended_search].all? { |value| value.nil? || value.to_s.strip.empty? }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/collections/cursor_collection.rb around lines 119-121:

Requests carrying a nonempty `filter.search_extended` return incorrect results: an `id = X` request returns `X` without applying the extended search, while a list request returns an unfiltered page instead of refusing the unsupported search. Both `id_lookup` and `browsing?` rely on `blank_search?`, which checks only `filter.search`; include `filter.search_extended` in that check so these requests are rejected.

spec.add_dependency 'activesupport', '>= 6.1'
spec.add_dependency 'faraday', '~> 2.0'
spec.add_dependency 'faraday-retry', '~> 2.0'
spec.add_dependency 'zeitwerk', '~> 2.3'

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_intercom/forest_admin_datasource_intercom.gemspec:35

Installing the published gem without the development Gemfile causes require 'forest_admin_datasource_intercom' to raise LoadError because the entrypoint unconditionally requires forest_admin_datasource_toolkit. Declare forest_admin_datasource_toolkit as a runtime dependency in the gemspec so RubyGems installs it for consumers.

   spec.add_dependency 'zeitwerk', '~> 2.3'
+  spec.add_dependency 'forest_admin_datasource_toolkit'
Also found in 1 other location(s)

packages/forest_admin_datasource_intercom/Gemfile:6

forest_admin_datasource_toolkit is added only to this development Gemfile, not to the gemspec. The published gem's entrypoint unconditionally requires it, so an application that installs forest_admin_datasource_intercom from RubyGems will not receive the toolkit dependency and fails with LoadError when loading the datasource.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/forest_admin_datasource_intercom.gemspec around line 35:

Installing the published gem without the development `Gemfile` causes `require 'forest_admin_datasource_intercom'` to raise `LoadError` because the entrypoint unconditionally requires `forest_admin_datasource_toolkit`. Declare `forest_admin_datasource_toolkit` as a runtime dependency in the gemspec so RubyGems installs it for consumers.

Also found in 1 other location(s):
- packages/forest_admin_datasource_intercom/Gemfile:6 -- `forest_admin_datasource_toolkit` is added only to this development `Gemfile`, not to the gemspec. The published gem's entrypoint unconditionally `require`s it, so an application that installs `forest_admin_datasource_intercom` from RubyGems will not receive the toolkit dependency and fails with `LoadError` when loading the datasource.

return if @base_url.nil?

uri = URI.parse(@base_url)
return if uri.is_a?(URI::HTTP) && !blank?(uri.host)

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_intercom/configuration.rb:99

validate_base_url! accepts http:// URLs, so Client#build_connection sends the bearer token and conversation data unencrypted to an HTTP endpoint, allowing network observers to steal the credential and read responses. Require URI::HTTPS here, or make HTTP an explicit opt-in for test-only endpoints.

Suggested change
return if uri.is_a?(URI::HTTP) && !blank?(uri.host)
return if uri.is_a?(URI::HTTPS) && !blank?(uri.host)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/configuration.rb around line 99:

`validate_base_url!` accepts `http://` URLs, so `Client#build_connection` sends the bearer token and conversation data unencrypted to an HTTP endpoint, allowing network observers to steal the credential and read responses. Require `URI::HTTPS` here, or make HTTP an explicit opt-in for test-only endpoints.

wanted = ids.first(MAX_ID_READS)
warn_truncated_ids(ids.size) if ids.size > wanted.size

wanted.filter_map do |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.

🟡 Medium collections/cursor_collection.rb:151

Primary-key IN list responses preserve the caller-supplied ID order instead of the effective ascending id order, so id IN ['3','1','2'] returns 3,1,2 and silently violates the default ordering. records_by_ids iterates ids directly, while warn_ignored_sort suppresses the warning for this injected default sort; sort the IDs before fetching.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/collections/cursor_collection.rb around line 151:

Primary-key `IN` list responses preserve the caller-supplied ID order instead of the effective ascending `id` order, so `id IN ['3','1','2']` returns `3,1,2` and silently violates the default ordering. `records_by_ids` iterates `ids` directly, while `warn_ignored_sort` suppresses the warning for this injected default sort; sort the IDs before fetching.

Found on a real workspace: the ticket list answered 400 in five
milliseconds, before any call to Intercom.

Forest lists the fields of a request in a comma-separated query
parameter, and a workspace names its ticket attributes in free text. One
of them is called "ID de l'objet en question (immo, facture, user)": the
agent splits that on the commas, gets three fields no collection has,
and rejects the projection. Nothing was wrong with the read -- the count,
which sends no field list, answered 200 the whole time.

The introspector now derives a column name from the workspace's own: the
commas and colons that are separators in Forest's protocol become
spaces, and the HTML escaping Intercom hands back -- `j&#39;ai` -- is
undone, since that is an artefact of where the name was typed rather
than part of it. The payload key stays the workspace's name, because
that is what `ticket_attributes` is keyed by; only the schema sees the
derived one.

Two attributes reading as the same column would share an entry and the
second's values would be read under the first's name, which is worse
than missing them: the second is left out with a log line naming both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
return entry 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]


entry = union[column]
return union[column] = attribute_from(name, column, definition) if entry.nil?
return entry if entry.name == name

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 schema/ticket_attributes_introspector.rb:100

Same-named attributes with different data_types are merged into one Attribute, so the first definition's schema is applied to values from every ticket type; this produces incorrect types and leaves epoch date values unconverted when the retained type is not Date. Only merge the entry when the data_type also matches, otherwise treat it as a collision and omit the conflicting definition.

-        return entry if entry.name == name
+        return entry if entry.name == name && entry.data_type == definition['data_type']
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/schema/ticket_attributes_introspector.rb around line 100:

Same-named attributes with different `data_type`s are merged into one `Attribute`, so the first definition's schema is applied to values from every ticket type; this produces incorrect types and leaves epoch date values unconverted when the retained type is not `Date`. Only merge the entry when the `data_type` also matches, otherwise treat it as a collision and omit the conflicting definition.

# attribute columns, never the boot of the agent.
def attributes
@attributes ||= build
rescue APIError => e

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 schema/ticket_attributes_introspector.rb:55

A transient APIError during fetch_all('ticket_types', boot: true) permanently memoizes attributes as [], so the IntercomTicket schema boots without custom attribute columns until restart. Because Client#fetch_all also maps timeouts, 5xx responses, and malformed responses to APIError, this rescues non-authorization failures as if permission were missing; retry introspection or propagate those failures instead of caching the empty result.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/schema/ticket_attributes_introspector.rb around line 55:

A transient `APIError` during `fetch_all('ticket_types', boot: true)` permanently memoizes `attributes` as `[]`, so the `IntercomTicket` schema boots without custom attribute columns until restart. Because `Client#fetch_all` also maps timeouts, 5xx responses, and malformed responses to `APIError`, this rescues non-authorization failures as if permission were missing; retry introspection or propagate those failures instead of caching the empty result.

A lot that writes nothing was publishing editable columns: neither
add_column set is_read_only, so the six collections offered a Save and a
Delete in the interface, both reaching the update and delete the
collections do not implement -- a 500 where the schema should simply not
have offered the button.

The two tiers already refuse on the read side what Intercom cannot
honour; this is the same rule on the write side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IN_MEMORY_OPERATORS = [Operators::IN, Operators::EQUAL, Operators::LESS_THAN, Operators::GREATER_THAN,
Operators::MATCH, Operators::STARTS_WITH, Operators::ENDS_WITH,
Operators::LONGER_THAN, Operators::SHORTER_THAN, Operators::INCLUDES_ALL,
Operators::NOT_IN, Operators::NOT_EQUAL, Operators::NOT_CONTAINS].freeze

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 collections/fetch_all_collection.rb:39

NOT_CONTAINS filters return every record, including records containing the requested text. Listing NOT_CONTAINS in IN_MEMORY_OPERATORS makes ConditionTreeLeaf#match treat its nested non-native contains result of nil as true; remove it from the advertised native operators until a valid equivalence is provided.

-                             Operators::NOT_IN, Operators::NOT_EQUAL, Operators::NOT_CONTAINS].freeze
+                             Operators::NOT_IN, Operators::NOT_EQUAL].freeze
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/collections/fetch_all_collection.rb around line 39:

`NOT_CONTAINS` filters return every record, including records containing the requested text. Listing `NOT_CONTAINS` in `IN_MEMORY_OPERATORS` makes `ConditionTreeLeaf#match` treat its nested non-native `contains` result of `nil` as true; remove it from the advertised native operators until a valid equivalence is provided.

Comment on lines +148 to +152
cursor = nil
pages = 0

loop do
body = get(path, cursor.nil? ? nil : { 'starting_after' => cursor }, boot: boot).body

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium forest_admin_datasource_intercom/client.rb:148

fetch_all requests a repeated pages.next.starting_after cursor and appends that page's records again, so malformed pagination returns duplicate reference rows before the collection cap stops it. Track already-followed cursors and reject a repeat before issuing another request, as CursorWalker does.

       pages = 0
+      seen_cursors = []
 
       loop do
+        refuse_body_shape(path, "'pages.next' repeats a cursor") if seen_cursors.include?(cursor)
+        seen_cursors << cursor
         body = get(path, cursor.nil? ? nil : { 'starting_after' => cursor }, boot: boot).body
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/client.rb around lines 148-152:

`fetch_all` requests a repeated `pages.next.starting_after` cursor and appends that page's records again, so malformed pagination returns duplicate reference rows before the collection cap stops it. Track already-followed cursors and reject a repeat before issuing another request, as `CursorWalker` does.

return records if page.nil?

offset = page.offset.to_i.clamp(0, nil)
limit = page.limit.to_i

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 collections/base_collection.rb:51

A page with an offset and limit: nil raises NoMethodError instead of returning all records after the offset. page.limit.to_i dereferences nil, so handle a missing limit as unbounded before converting it.

Suggested change
limit = page.limit.to_i
limit = page.limit&.to_i
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/collections/base_collection.rb around line 51:

A page with an offset and `limit: nil` raises `NoMethodError` instead of returning all records after the offset. `page.limit.to_i` dereferences `nil`, so handle a missing limit as unbounded before converting it.

# Rows come back keyed with strings because that is how the agent reads
# them, while `Aggregation#apply` hands them back keyed with symbols.
def aggregate(caller, filter, aggregation, limit = nil)
aggregation.apply(filtered_records(caller, filter), timezone_for(caller), limit)

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 collections/fetch_all_collection.rb:70

aggregate accepts Sum and Avg for String columns and returns 0 instead of rejecting the invalid aggregation. Aggregation#apply counts truthy strings while adding only Numeric values, so charts for fields such as name, email, or id display a plausible but false result; validate the aggregation against the column type before calling apply.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/collections/fetch_all_collection.rb around line 70:

`aggregate` accepts `Sum` and `Avg` for String columns and returns `0` instead of rejecting the invalid aggregation. `Aggregation#apply` counts truthy strings while adding only Numeric values, so charts for fields such as `name`, `email`, or `id` display a plausible but false result; validate the aggregation against the column type before calling `apply`.

Comment on lines +84 to +85
@remaining -= 1 if @remaining
return [0, nil] unless exhausted?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium forest_admin_datasource_intercom/rate_limiter.rb:84

acquire sleeps when X-RateLimit-Remaining is 1, withholding the final request that Intercom says is still available and unnecessarily delaying operations. plan_wait decrements @remaining before testing exhaustion, turning 1 into 0; check the pre-decrement exhaustion state while still decrementing the local budget for the request being admitted.

-      @remaining -= 1 if @remaining
-      return [0, nil] unless exhausted?
+      was_exhausted = exhausted?
+      @remaining -= 1 if @remaining
+      return [0, nil] unless was_exhausted
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/rate_limiter.rb around lines 84-85:

`acquire` sleeps when `X-RateLimit-Remaining` is `1`, withholding the final request that Intercom says is still available and unnecessarily delaying operations. `plan_wait` decrements `@remaining` before testing exhaustion, turning `1` into `0`; check the pre-decrement exhaustion state while still decrementing the local budget for the request being admitted.

# one page would otherwise collect the same pages until a cap cut it
# short.
def stop?(page, seen_cursors)
page.next_cursor.nil? || page.records.empty? || !seen_cursors.add?(page.next_cursor)

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 pagination/cursor_walker.rb:100

An empty intermediate page causes walk to return the records collected so far, silently omitting records available through page.next_cursor. Because stop? breaks before the cap check, this truncation is not logged; follow a non-nil cursor even when the current page has no records.

-        page.next_cursor.nil? || page.records.empty? || !seen_cursors.add?(page.next_cursor)
+        page.next_cursor.nil? || !seen_cursors.add?(page.next_cursor)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/pagination/cursor_walker.rb around line 100:

An empty intermediate page causes `walk` to return the records collected so far, silently omitting records available through `page.next_cursor`. Because `stop?` breaks before the cap check, this truncation is not logged; follow a non-nil cursor even when the current page has no records.

def filtered_records(caller, filter)
records = fetch_all.map { |entity| serialize(entity) }
tree = filter&.condition_tree
return records if tree.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.

🟠 High collections/fetch_all_collection.rb:106

Free-text searches on these collections return and count every endpoint row instead of only matching rows. filtered_records checks only filter.condition_tree, so it ignores filter.search and filter.search_extended when no condition tree exists; apply the search or explicitly reject unsupported searches, as the cursor tier does.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/collections/fetch_all_collection.rb around line 106:

Free-text searches on these collections return and count every endpoint row instead of only matching rows. `filtered_records` checks only `filter.condition_tree`, so it ignores `filter.search` and `filter.search_extended` when no condition tree exists; apply the search or explicitly reject unsupported searches, as the cursor tier does.

return [0, nil] unless exhausted?

wait = clamp_wait(@reset_at - @now.call)
return [0, nil] if wait <= 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium forest_admin_datasource_intercom/rate_limiter.rb:88

After @reset_at has passed, concurrent acquire calls are all released without a wait until a new response arrives, so a queued burst can exceed the newly reset window's @limit and trigger avoidable 429s. The wait <= 0 branch leaves @remaining at zero/negative; restore the local budget from @limit when the reset is reached before returning.

Suggested change
return [0, nil] if wait <= 0
if wait <= 0
@remaining = @limit
return [0, nil]
end
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/rate_limiter.rb around line 88:

After `@reset_at` has passed, concurrent `acquire` calls are all released without a wait until a new response arrives, so a queued burst can exceed the newly reset window's `@limit` and trigger avoidable 429s. The `wait <= 0` branch leaves `@remaining` at zero/negative; restore the local budget from `@limit` when the reset is reached before returning.

Comment on lines +74 to +76
def collect(ticket_type, union)
type_id = ticket_type['id'].to_s
definitions(ticket_type).each do |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.

🟠 High schema/ticket_attributes_introspector.rb:74

A null or non-object element in the ticket types array raises at ticket_type['id'], aborting datasource construction instead of returning the intended empty attribute schema. Guard ticket_type before indexing it so definitions can safely handle invalid elements.

 def collect(ticket_type, union)
-        type_id = ticket_type['id'].to_s
+        return unless ticket_type.is_a?(Hash)
+        type_id = ticket_type['id'].to_s
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_intercom/lib/forest_admin_datasource_intercom/schema/ticket_attributes_introspector.rb around lines 74-76:

A `null` or non-object element in the ticket types array raises at `ticket_type['id']`, aborting datasource construction instead of returning the intended empty attribute schema. Guard `ticket_type` before indexing it so `definitions` can safely handle invalid elements.

@christophebrun-forest
christophebrun-forest merged commit 71eecb7 into feat/datasource-intercom Sep 1, 2026
33 checks passed
@christophebrun-forest
christophebrun-forest deleted the feat/prd-1112-tickets-conversations-read branch September 1, 2026 15:43
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