Skip to content

feat(datasource): reply to a Pylon issue from Forest on the customer's channel [PRD-1873] - #420

Open
christophebrun-forest wants to merge 7 commits into
mainfrom
feature/prd-1873-pylon-datasource-reply-to-an-issue-from-forest-on-the
Open

christophebrun-forest wants to merge 7 commits into
mainfrom
feature/prd-1873-pylon-datasource-reply-to-an-issue-from-forest-on-the

Conversation

@christophebrun-forest

@christophebrun-forest christophebrun-forest commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

What

Adds a ReplyToIssue action plugin to forest_admin_datasource_pylon. An operator can answer a Pylon issue from Forest, and the reply reaches the customer on the channel they used: Slack stays Slack, email stays email. The operator can post an internal note instead.

Linear: PRD-1873

How

Pylon keeps the channel: POST /issues/{id}/reply delivers the reply into the conversation of the message named by message_id. The plugin picks that message and, for email, the recipients, which Pylon does not infer.

  • Channel rule (ReplyTarget): the channel is the source of the first customer-visible message. The reply targets the latest customer-visible message on that channel. With no customer-visible message, only an internal note is offered.
  • Email recipients are pre-filled from that message and stay editable:
    • when the customer wrote the message, To is its sender only, since the customer's own To holds the support inbox; when the team wrote it, To is whom it went to;
    • support_emails and the addresses the team wrote from in the thread are left out, from the pre-fill and from what the operator types.
  • Form (FormBuilder): reply type, channel (read-only), To and CC (email only), rich-text message. The thread is read once per render.
  • Execution:
    • the thread is read again with fetch_complete_issue_messages, which raises instead of degrading to nil, so an unreadable thread is never taken for an issue without a customer message;
    • an internal note needs no thread;
    • a Pylon 4xx is shown to the operator as Pylon words it;
    • any other failure asks the operator to check Pylon before resending, since the message may already have been delivered.
  • post_as_operator (off by default):
    • a public reply passes the user_id of the active Pylon user whose email matches the Forest caller's;
    • no match, several matches or a caller without an email refuse the reply. It never falls back to signing with the token;
    • internal notes are always signed by the token.
  • Client: reply_to_issue and create_issue_note, each with its own 30/min RateLimits rule. A 2xx is taken as delivered whatever its body.
  • Message thread: embedded messages now carry email_from, email_to and email_cc.

Reviewer notes

  • The channel is deliberately the one of the first customer-visible message, as decided in PRD-1873. An issue that started on another channel and moved to email is answered on the first channel.
  • The per-render cache is one strongly held entry per thread. ObjectSpace::WeakMap lets values be collected mid-render, and WeakKeyMap needs Ruby 3.3 while the gem supports 3.0.
  • Clicking a row of the embedded messages list shows an unrelated front error, tracked in PRD-1875.

Testing

  • bundle exec rspec in the package: 902 examples, 0 failures, 100% line coverage. bundle exec rubocop: clean.
  • Form checked manually from a local app on a real Pylon workspace.
  • Real sends tested on a Slack issue and an email issue.
  • Chat widget not tested and out of scope: it is not enabled on the client instance.

🤖 Generated with Claude Code

Note

Add ReplyToIssue plugin to reply to Pylon issues from Forest

  • Adds a ReplyToIssue plugin that registers a single-record action on the issue collection. It posts a public reply to the issue's resolved conversation target, or an internal note when chosen or no reply target exists.
  • Public replies target the latest customer-visible message on the channel set by the first customer-visible message. Email recipients are derived from the thread and filtered to drop support addresses, team-authored addresses, blanks, and duplicates.
  • The form builder renders a reply-type selector, read-only channel field, conditional To/CC fields, and a required rich-text message. Thread lookup runs once per render and is cached on the form context.
  • Optional operator signing resolves a single active Pylon user by the Forest caller's primary or alternate email. Missing, ambiguous, or failing lookups refuse to send.
  • Adds client methods to post replies and notes, plus a complete-thread read that raises on failure or truncated pagination. Adds email sender/To/CC fields to embedded issue messages.
  • Adds separate 30-requests-per-minute rate-limit buckets for POSTs to issue reply and note endpoints.
  • Behavioral Change: non-4xx write errors return a check-before-resending message; the existing best-effort thread read still returns nil on failure. Embedded messages now include email_from, email_to, and email_cc.

Macroscope summarized c4da6bd.

christophebrun-forest and others added 6 commits October 30, 2026 15:00
Client::Writes gains reply_to_issue and create_issue_note, posting to
/issues/{id}/reply and /issues/{id}/note with the id escaped. Both get
their own 30/min window in RateLimits instead of the shared fallback.

fetch_complete_issue_messages reads the thread for a caller acting on
it: a failed or truncated read raises instead of degrading to nil, so
an unreadable thread is never taken for one with no customer message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… [PRD-1873]

Each embedded message now carries email_from, email_to and email_cc,
flattened from Pylon's email_info and null outside email. A reply to an
email issue must name its recipients, which Pylon does not infer.

Blind copies and the RFC 5322 message id header stay out. Also corrects
the thread endpoint budget in the MessagesEmbedder comment (120/min).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Answers a Pylon issue from Forest on the channel the customer used, or
posts an internal note. The channel is the source of the first
customer-visible message; the reply targets the latest message on that
channel. Email recipients are pre-filled from that message, support
addresses left out, since Pylon does not infer them.

The form reads the thread once per render. The executor reads it again,
strictly: an unreadable thread is reported as such, never as an issue
without customer message. Pylon 4xx reach the operator as the action
error. Replies are signed by the API token account for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With post_as_operator, a public reply passes the user_id of the active
Pylon user whose email or emails match the Forest caller email, case
insensitive. No match, several matches or a caller without email
refuse the reply and send nothing: falling back to the token would sign
some replies in the operator name and others not, out of their sight.

Internal notes stay signed by the token account. Off by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Options, the channel rule, how email recipients are pre-filled, who
signs a reply, and what to know before granting the action: the
issue_id_field column is the authority, and post_as_operator trusts
the emails shared between Forest and Pylon.

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

- Recipients: the sender of the latest email joins To only when the
  customer wrote it, and the addresses the team wrote from in the thread
  are left out even when support_emails misses them.
- An internal note no longer needs the thread, so an unreadable thread
  still lets the operator leave one.
- A reply or note answered 2xx is taken as delivered whatever its body,
  and a non-4xx failure asks the operator to check Pylon before sending
  again instead of surfacing a bare 500.
- The per-render thread cache holds its state strongly: WeakMap values
  could be collected mid-render.
- An emptied rich-text editor (<p><br></p>) is refused as empty.
- The operator lookup asks Pylon for active users only.

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

linear-code Bot commented Oct 30, 2026

Copy link
Copy Markdown

PRD-1873

@qltysh

qltysh Bot commented Oct 30, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

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

Modified Files with Diff Coverage (8)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
..._admin_datasource_pylon/collections/issue/messages_embedder.rb100.0%
Coverage rating: A Coverage rating: A
...n_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb100.0%
Coverage rating: A Coverage rating: A
...ource_pylon/lib/forest_admin_datasource_pylon/client/writes.rb100.0%
New file Coverage rating: A
..._admin_datasource_pylon/plugins/reply_to_issue/reply_target.rb100.0%
New file Coverage rating: A
...admin_datasource_pylon/plugins/reply_to_issue/thread_lookup.rb100.0%
New file Coverage rating: A
...on/lib/forest_admin_datasource_pylon/plugins/reply_to_issue.rb100.0%
New file Coverage rating: A
..._admin_datasource_pylon/plugins/reply_to_issue/form_builder.rb100.0%
New file Coverage rating: A
...min_datasource_pylon/plugins/reply_to_issue/operator_lookup.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
christophebrun-forest marked this pull request as ready for review October 30, 2026 15:22
…D-1873]

- Answering a customer email writes to its sender only: the customer's
  own To holds the support inbox, which nothing else names before the
  team's first email when support_emails is not set.
- The support and team addresses are also removed from what the
  operator types in To and CC, and CC is deduplicated against To.
- An email_info that is not an object no longer raises out of the
  thread lookup.
- The per-render state is kept on the render context rather than in a
  thread-local slot that outlived the request.
- A literal non-breaking space or zero-width character no longer passes
  as a message, and a blank Pylon user name falls back to the email.
- issue_messages_path is private; the README states what a submit costs
  in thread reads, the agent rendering the form again before executing.

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

qltysh Bot commented Oct 30, 2026

Copy link
Copy Markdown

7 new issues

Tool Category Rule Count
qlty Structure Function with high complexity (count = 6): run 4
qlty Structure Function with many parameters (count = 5): reply 2
qlty Structure Function with many returns (count = 4): reply 1

support_emails: normalize_emails(options[:support_emails])
}

collection_customizer.add_action((options[:action_name] || NAME).to_s, build_action(datasource, opts))

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

note(datasource, issue_id, body)
end
result_builder.public_send(status, message: message)
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 = 9): executor [qlty:function-complexity]

text.gsub(/[[:space:]\u200B-\u200D\u2060\uFEFF]/, '').empty?
end

def reply(datasource, state, body, context, opts)

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

payload['user_id'] = operator.user['id'] if operator
write(state.issue_id, 'reply', reply_success(state, payload, operator&.user)) do
datasource.client.reply_to_issue(state.issue_id, payload)
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 2 issues:

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


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

opts[:allow_internal_note] ? "#{no_target} Post an internal note instead." : no_target
end

def recipients_field(label, state, opts, side)

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


Result.new(error: refusal(email, matches.size))
rescue APIError => e
Result.new(error: "Could not read the Pylon users to sign the reply in your name: #{e.message}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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