feat(zendesk): add plugins for creating tickets and closing actions#303
Conversation
… actions
Two smart actions on the Zendesk datasource, both configurable from
Datasource.new and reusable on any host collection via register_on:
- CreateTicketWithNotification (auto-registered on ZendeskUser)
Opens a ticket from a host record. The requester is identified by an
email entered in the form, optionally pre-filled by requester_email_default
(literal String at datasource level, String or Proc on register_on).
Subject and Message defaults support {{record.<field>}} interpolation.
Message uses the RichText widget and ships to Zendesk as html_body.
Optional ticket_id_field on register_on writes the new ticket id back to
a configured field on the host record (best-effort: failure logs a warn
and surfaces in the success message without rolling back the ticket).
- CloseTicket (opt-in on ZendeskTicket)
Two no-form actions per status (Single + Bulk) that flip the ticket status
to solved or closed. Opt-in via close_ticket_statuses: %w[solved closed]
on Datasource.new (empty by default).
Internal: BaseCollection delegates execute/get_form to an internal
ActionCollectionDecorator so actions registered at the datasource level get
the full form lifecycle (defaults, conditions, watch_changes) that the agent
applies to customizer-defined actions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-up fixes from code review:
- CreateTicketWithNotification: HTML-escape {{record.<field>}} tokens when
interpolating into the Message template. Without this, a record value
containing `<`, `&`, or markup would break the outbound HTML or smuggle
markup into the email Zendesk triggers send to the requester. Subject
interpolation remains unescaped since it's a plain-text field.
- CloseTicket: walk ticket ids one by one inside the executor instead of
letting the first Zendesk API error abort the rest of a bulk run. The
most common case is Zendesk rejecting the direct open -> closed
transition; previously the entire action returned a 500. Now:
* All ids succeed: Success with the count.
* Some fail (bulk): Success with the success count + list of failed ids.
* All fail: Error with the underlying API reason.
Each failure is also logged via the package logger.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up review nits, none functional: - CreateTicketWithNotification: log a warn before swallowing StandardError in `requester_default` and `fetch_record`. Previously, a typo in a user-supplied resolver lambda or a transient list failure would leave the email field silently empty with no signal anywhere. - Datasource: `.uniq` on `close_ticket_statuses` so an accidental `%w[solved solved closed]` no longer crashes registration with "Action ... already defined". - ASCII em-dash in the writeback-failure success message replaced by a colon, avoiding encoding hazards on downstream consumers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9 new issues
|
The new files were sitting at 19-35% comment density while the rest of the Zendesk package is at 0-2%. Dropped tutorial-style docstrings and restating comments; kept only the genuinely non-obvious notes (Zendesk open->closed transition, html_body escaping, writeback best-effort, internal ActionCollectionDecorator reuse). Final density: 3-6%, in line with the surrounding code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rrides
Four new optional kwargs on Actions::CreateTicketWithNotification, all
also propagated through Datasource.new for the ZendeskUser auto-registration:
- action_name: overrides the label the action is registered under (defaults
to 'Create ticket and notify' as before).
- email_templates: array of { title:, content: } hashes. When present, the
form becomes a two-page wizard: page 1 picks a template (or 'No template'),
page 2 is the body form with Message pre-filled from the selection.
'No template' yields a strictly empty Message; default_ticket_message is
ignored when templates are configured (strict opt-in to the wizard).
- priority_override: when set, the Priority dropdown is removed from the form
and this value is forced in the payload sent to Zendesk.
- type_override: same for Type. Useful when Zendesk's setup requires those
fields but you don't want the agent to choose.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related bugs in the template wizard:
1. The Message field used `default_value:` so drop_default skipped the
proc once data['Message'] was cached, meaning selecting a template
never refreshed the field. Switch to `value:` (re-evaluated every
form fetch by drop_deferred) and gate the proc on
`field_changed?('Template')`: emit the content when Template just
changed, return nil otherwise so the agent's set_watch_changes
carries over whatever the user typed.
2. `{{record.<field>}}` tokens inside a template's content were not
interpolated. Run the selected template content through the same
HTML-escaping interpolate helper used for default_ticket_message
(short-circuit when no token is present to skip the record fetch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- sender_email kwarg on Datasource.new and register_on. Mapped to
Zendesk's `recipient` field in the create-ticket payload (the support
address the ticket is tied to, which is also the From address of the
notification email sent to the requester). Propagated from the
datasource to the ZendeskUser auto-registered action.
- Fix ZendeskAPI::Error::RecordInvalid 'Requester: Name: is too short':
Zendesk auto-creates the requester user from the email when no match,
but its validation requires a non-empty name. Derive name from the
email's local-part ('john.doe@acme.com' -> 'john.doe') so the create
step succeeds. Ignored when the email maps to an existing user.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops auto-registration on ZendeskTicket / ZendeskUser and slims the Datasource constructor to just credentials. CreateTicketWithNotification and CloseTicket are now opted in per host collection via plugins, with the Zendesk ticket id read from a configurable column on the host record (e.g. last_zendesk_ticket_id). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Inline Actions::* into their Plugins:: counterparts; drop the
actions/ directory and the dead ActionCollectionDecorator plumbing
in BaseCollection (the customizer wraps collections itself now).
- Extract FormBuilder and Messages sub-modules to keep each plugin file
focused on registration and orchestration.
- Share Zendesk enum values via a new TicketEnums module so the schema
and form builder reference a single source.
- CloseTicket: replace the single statuses option with orthogonal
statuses + scopes so callers can pick solved vs closed and single
vs bulk independently.
- CloseTicket: swap Zendesks raw "closed prevents ticket update" stack
for a clean message: success ("was already closed") when targeting
closed, Error ("cannot reopen to mark as solved") when targeting solved.
- Trim over-documented comments and route specs through #run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| end | ||
| end | ||
|
|
||
| def build_action(datasource, status, scope, ticket_id_field) |
| else | ||
| result_builder.success(message: Messages.success(succeeded, already_closed, failed, status)) | ||
| end | ||
| end |
| failed << [id, "#{e.class}: #{e.message}"] | ||
| end | ||
| end | ||
| [succeeded, already_closed, failed] |
| module Messages | ||
| module_function | ||
|
|
||
| def success(succeeded, already_closed, failed, status) |
| payload['type'] = type if present?(type) | ||
| # Zendesk's `recipient` = the support address replies come FROM. | ||
| payload['recipient'] = opts[:sender_email] if present?(opts[:sender_email]) | ||
| payload |
| "[forest_admin_datasource_zendesk] requester_email_default resolver raised: #{e.class}: #{e.message}" | ||
| ) | ||
| nil | ||
| end |
| return content unless content.match?(TOKEN_RE) | ||
|
|
||
| interpolate(content, fetch_record(context), escape_html: true) | ||
| end |
| next '' if value.nil? | ||
|
|
||
| escape_html ? CGI.escapeHTML(value.to_s) : value.to_s | ||
| end |
The status and scope option normalizers shared the same shape; collapse them into a single `normalize(value, cast, allowed, label)` helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| normalize(value, :to_sym, SCOPE_KEYS, 'scopes') | ||
| end | ||
|
|
||
| def normalize(value, cast, allowed, label) |
Hides the toggle by default so tickets are public unless an integrator opts in, mirroring the priority_override gating pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthv
left a comment
There was a problem hiding this comment.
Given how the PR has evolved, I suggest a slightly more appropriate title: feat(zendesk): add plugins for creating tickets upon registration and closing actions
| end | ||
|
|
||
| def already_closed?(error) | ||
| error.message.to_s.include?(ALREADY_CLOSED_PATTERN) |
There was a problem hiding this comment.
Relies on a brittle string match. We could match via e.cause (the structured RecordInvalid) instead, IMO it's more robust
| raise ArgumentError, 'CloseTicket plugin requires :datasource' unless datasource | ||
| raise ArgumentError, 'CloseTicket plugin requires :ticket_id_field' unless ticket_id_field | ||
| raise ArgumentError, 'CloseTicket plugin requires a collection' unless collection_customizer |
There was a problem hiding this comment.
Could we unify the config-error raises in #run to ForestException (currently ArgumentError)? That's what Plugins::AddExternalRelation does for the same case, and it lets the caller rescue a single type. The plugins already mix both types depending on the validation step, so no real reason to keep the split.
| records = context.get_records([ticket_id_field]) | ||
| records = [records].compact unless records.is_a?(Array) |
There was a problem hiding this comment.
the records = [records].compact unless records.is_a?(Array) guard can never trigger, get_records delegates to collection.list which always returns an Array. Any reason to keep it, or can we just drop it?
There was a problem hiding this comment.
yes drop it
| def resolve_ticket_ids(context, ticket_id_field) | ||
| records = context.get_records([ticket_id_field]) | ||
| records = [records].compact unless records.is_a?(Array) | ||
| records.filter_map { |r| r[ticket_id_field] || r[ticket_id_field.to_sym] } |
There was a problem hiding this comment.
The r[ticket_id_field] || r[ticket_id_field.to_sym] fallback in resolve_ticket_ids, all Forest datasources return string-keyed records. The symbol fallback covers a scenario that doesn't happen in the framework. Could we simplify to r[ticket_id_field.to_s] and drop the "works with symbol keys" test?
| def interpolate(template, record, escape_html:) | ||
| template.gsub(TOKEN_RE) do | ||
| key = ::Regexp.last_match(1) | ||
| value = record[key] || record[key.to_sym] |
There was a problem hiding this comment.
Same string/symbol issue as resolve_ticket_ids. Same fix here.
|
|
||
| payload = build_payload(values, email, opts) | ||
| ticket = datasource.client.create_ticket(payload) | ||
| ticket_id = ticket.respond_to?(:[]) ? ticket['id'] : nil |
There was a problem hiding this comment.
IMO The ticket.respond_to?(:[]) ? ticket['id'] : nil masks a contract issue with Client#create_ticket. If the return shape isn't guaranteed, that should be fixed in writes.rb rather than guarded here. The silent nil makes the success message say "Ticket created" without an id, which is hard to debug.
Could we either drop the guard and let ticket['id'] raise naturally if the shape is wrong, or check explicitly and raise a typed error with context?
|
|
||
| def requester_default(value) | ||
| return nil if value.nil? | ||
| return value if value.is_a?(String) |
There was a problem hiding this comment.
WDYT about running template_default(value, escape_html: false) when value.is_a?(String). Aligns it with the other String defaults' token support.
There was a problem hiding this comment.
right, done
- close_ticket: detect "already closed" via structured `e.cause`
(ZendeskAPI::Error::RecordInvalid#errors) instead of message match;
extract the classifier into a CloseTicket::Errors submodule;
unify `#run` config errors to ForestException; drop dead Array
guard and symbol-key fallback.
- create_ticket_with_notification: same ForestException unification;
drop defensive `respond_to?(:[])` guard and `unless ticket_id`
fallbacks now that the client contract is tight; route the
Requester email String default through `template_default` so
`{{record.*}}` tokens resolve like Subject/Message.
- form_builder: drop symbol-key fallback in `interpolate`.
- client/writes: extract resource via `extract_resource`, raising a
typed APIError when the `{ "<resource>": {...} }` envelope is
missing -- runs outside `must_succeed` to avoid double-wrap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# [1.30.0](v1.29.2...v1.30.0) (2026-05-18) ### Features * **zendesk:** add plugins for creating tickets and closing actions ([#303](#303)) ([ad624e5](ad624e5))
|
🎉 This PR is included in version 1.30.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
Two opt-in plugins for the
forest_admin_datasource_zendeskpackage, registered through the standard customizer plugin mechanism (collection_customizer.add_plugin(plugin_class, options)). Neither plugin runs by default — covered bydatasource_spec.rb#registers no smart actions by default.Plugins::CreateTicketWithNotification(Single scope). Host-agnostic — Zendesk creates the requester user on the fly from the form's email, so the plugin can attach to any collection. Form fields:Requester email(required, pre-filled viarequester_email_default:— String or Proc),Subject,Message(RichText →html_body),Priority,Type,Send as internal note. Supports{{record.<field>}}token interpolation indefault_subject/default_message, HTML-escaped in Message to block injection. Knobs:action_name,priority_override/type_override(force a value and hide the field),sender_email(maps to Zendeskrecipient),email_templates(flips the form into a two-page wizard with a Template dropdown),ticket_id_field(best-effort writeback of the new ticket id to the host record — a writeback failure is logged and surfaced in the success message without rolling back the Zendesk ticket).Plugins::CloseTicket(Single + Bulk ×solved/closed). Reads the Zendesk ticket id from a configurable host field (ticket_id_field:), filters variants viastatuses:/scopes:. Per-id rescue so a single transition rejection (e.g.open → closed) doesn't abort the rest of a bulk run; partial successes and failures are surfaced in the result message. Detects Zendesk's "closed prevents ticket update" wrapper to keep "already closed" idempotent forstatus=closed(success), and surfaces a clean error forstatus=solved(cannot reopen).STATUS/PRIORITY/TYPE) extracted toTicketEnums, consumed by both the Ticket schema and the form builder.Test plan
bundle exec rspec— 220 examples, 0 failures, 99% coveragebundle exec rubocop— 0 offensesCreateTicketWithNotificationto a host collection withrequester_email_default:(Proc), verify the email pre-fills from the selected recordemail_templates:and confirm the two-page wizard, Template selection, and{{record.<field>}}interpolation in the rendered MessageCloseTicketwithstatuses: %w[solved closed], run a bulk close on a mix including one ticket in a state Zendesk rejects, verify the others still transition and the failed id is reportedticket_id_field:onCreateTicketWithNotification, create a ticket, verify the host record's column is updated; then force the writeback to fail and verify the ticket is still created and the warning surfaces in the success message🤖 Generated with Claude Code
Note
Add smart actions for Zendesk ticket creation and closure to the Zendesk datasource
CloseTicketplugin that registers smart actions (single and bulk scope) to set Zendesk ticket status tosolvedorclosed, reporting granular success/failure and treating already-closed tickets idempotently.CreateTicketWithNotificationplugin that registers a single-scope action to create a Zendesk ticket from a form, optionally notify the requester, and write the new ticket ID back to the host record.STATUS,PRIORITY,TYPE) into a newTicketEnumsmodule consumed by both plugins and the existing schema definition.forest_admin_datasource_customizeras a runtime dependency; both plugins inherit from itsPluginbase class.Changes since #303 opened
ArgumentErrorwithForestExceptionfor validation errors across plugins [42789c9]Macroscope summarized 8f6540d.