feat(zendesk): smart actions for ticket creation and closure#303
Open
christophebrun-forest wants to merge 11 commits into
Open
feat(zendesk): smart actions for ticket creation and closure#303christophebrun-forest wants to merge 11 commits into
christophebrun-forest wants to merge 11 commits into
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>
10 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) |
|
|
||
| writeback = write_back_ticket_id(context, opts[:ticket_id_field], ticket_id) | ||
| result_builder.success(message: success_message(ticket_id, values, writeback)) | ||
| end |
| 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Macroscope summarized 8f6540d.