Skip to content

feat(zendesk): smart actions for ticket creation and closure#303

Open
christophebrun-forest wants to merge 11 commits into
mainfrom
feat/zendesk-create-ticket-action
Open

feat(zendesk): smart actions for ticket creation and closure#303
christophebrun-forest wants to merge 11 commits into
mainfrom
feat/zendesk-create-ticket-action

Conversation

@christophebrun-forest
Copy link
Copy Markdown
Member

@christophebrun-forest christophebrun-forest commented May 13, 2026

Summary

Two opt-in plugins for the forest_admin_datasource_zendesk package, registered through the standard customizer plugin mechanism (collection_customizer.add_plugin(plugin_class, options)). Neither plugin runs by default — covered by datasource_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 via requester_email_default: — String or Proc), Subject, Message (RichText → html_body), Priority, Type, Send as internal note. Supports {{record.<field>}} token interpolation in default_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 Zendesk recipient), 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 via statuses: / 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 for status=closed (success), and surfaces a clean error for status=solved (cannot reopen).
  • Shared: ticket enum values (STATUS/PRIORITY/TYPE) extracted to TicketEnums, consumed by both the Ticket schema and the form builder.

Test plan

  • bundle exec rspec — 220 examples, 0 failures, 99% coverage
  • bundle exec rubocop — 0 offenses
  • Manual: attach CreateTicketWithNotification to a host collection with requester_email_default: (Proc), verify the email pre-fills from the selected record
  • Manual: configure email_templates: and confirm the two-page wizard, Template selection, and {{record.<field>}} interpolation in the rendered Message
  • Manual: attach CloseTicket with statuses: %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 reported
  • Manual: configure ticket_id_field: on CreateTicketWithNotification, 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

  • Adds a CloseTicket plugin that registers smart actions (single and bulk scope) to set Zendesk ticket status to solved or closed, reporting granular success/failure and treating already-closed tickets idempotently.
  • Adds a CreateTicketWithNotification plugin 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.
  • The create action form supports record-based field interpolation, an optional two-page template selection wizard, and safe HTML handling for the message body.
  • Extracts shared ticket enum constants (STATUS, PRIORITY, TYPE) into a new TicketEnums module consumed by both plugins and the existing schema definition.
  • Adds forest_admin_datasource_customizer as a runtime dependency; both plugins inherit from its Plugin base class.

Macroscope summarized 8f6540d.

christophebrun-forest and others added 3 commits May 13, 2026 09:49
… 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>
@qltysh
Copy link
Copy Markdown

qltysh Bot commented May 13, 2026

10 new issues

Tool Category Rule Count
qlty Structure Function with high complexity (count = 10): executor 7
qlty Structure Function with many parameters (count = 4): normalize 3

christophebrun-forest and others added 2 commits May 13, 2026 11:24
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)
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): build_action [qlty:function-parameters]

else
result_builder.success(message: Messages.success(succeeded, already_closed, failed, status))
end
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with high complexity (count = 10): executor [qlty:function-complexity]

failed << [id, "#{e.class}: #{e.message}"]
end
end
[succeeded, already_closed, failed]
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): apply_status [qlty:function-complexity]

module Messages
module_function

def success(succeeded, already_closed, failed, status)
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): success [qlty:function-parameters]


writeback = write_back_ticket_id(context, opts[:ticket_id_field], ticket_id)
result_builder.success(message: success_message(ticket_id, values, writeback))
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 = 5): executor [qlty:function-complexity]

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

"[forest_admin_datasource_zendesk] requester_email_default resolver raised: #{e.class}: #{e.message}"
)
nil
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with high complexity (count = 6): requester_default [qlty:function-complexity]

return content unless content.match?(TOKEN_RE)

interpolate(content, fetch_record(context), escape_html: true)
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with high complexity (count = 6): message_value [qlty:function-complexity]

next '' if value.nil?

escape_html ? CGI.escapeHTML(value.to_s) : value.to_s
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with high complexity (count = 6): interpolate [qlty:function-complexity]

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

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