Skip to content

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

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

feat(zendesk): smart actions for ticket creation and closure#303
christophebrun-forest wants to merge 9 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

  • CreateTicketWithNotification smart action (Single scope). Auto-registered on ZendeskUser, and reusable on any host collection via Actions::CreateTicketWithNotification.register_on(collection, datasource, ...). Pre-fills a "Requester email" form field (literal String or Proc resolver), supports {{record.<field>}} token interpolation in Subject/Message defaults (HTML-escaped in the RichText Message to prevent injection), and ships the comment to Zendesk as html_body. Optional ticket_id_field writes the new ticket id back to the host record (best-effort: a writeback failure is logged and surfaced in the success message without rolling back the ticket).
  • CloseTicket smart actions (Single + Bulk, opt-in via close_ticket_statuses: %w[solved closed] on Datasource.new). Per-id rescue so a Zendesk transition rejection (e.g. open -> closed) doesn't abort the rest of a bulk run; partial-success surfaces the failed ids in the result message.
  • 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) the agent applies to customizer-defined actions.

Test plan

  • bundle exec rspec -- 220 examples, 0 failures, 99% coverage
  • bundle exec rubocop -- 0 offenses
  • Manual: create a ticket from a ZendeskUser detail page, verify the requester email is pre-filled
  • Manual: attach the action to a non-Zendesk collection via register_on(...) and confirm token interpolation reads the host record
  • Manual: enable close_ticket_statuses: %w[solved closed], run a bulk close on a mix of tickets including one in a state Zendesk rejects, verify the others still transition and the failed id is reported
  • Manual: configure ticket_id_field and verify the host record's column is updated after ticket creation

🤖 Generated with Claude Code

Note

Add smart action plugins for Zendesk ticket creation and closure

  • Adds CloseTicket plugin that registers SINGLE and BULK scoped smart actions to mark Zendesk tickets as solved or closed, with per-id error handling and idempotent detection of already-closed tickets.
  • Adds CreateTicketWithNotification plugin that registers a smart action to create a Zendesk ticket from a dynamic form, optionally notifying the requester via email and writing the created ticket id back to the host record.
  • The form supports a two-page wizard mode when email_templates are provided, with token interpolation ({{record.<field>}}) and HTML escaping for message content.
  • Extracts shared enum values into a TicketEnums module consumed by both the schema and the new plugins.
  • Both plugins are opt-in and not registered by default on the datasource collections.

Macroscope summarized 8aca51b.

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

9 new issues

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

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]

Comment on lines +102 to +104
def present?(value)
!value.nil? && 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.

🟢 Low plugins/create_ticket_with_notification.rb:102

The present? helper considers whitespace-only strings as present (" ".present? is true), so an email field containing only spaces passes validation at line 32. The code then calls create_ticket with an invalid whitespace-only email, which likely causes Zendesk API errors or creates malformed tickets. Consider stripping whitespace before the empty check, or using a stricter validation that rejects whitespace-only input.

      def present?(value)
-        !value.nil? && value.to_s != ''
+        !value.nil? && value.to_s.strip != ''
      end
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/plugins/create_ticket_with_notification.rb around lines 102-104:

The `present?` helper considers whitespace-only strings as present (`"   ".present?` is true), so an email field containing only spaces passes validation at line 32. The code then calls `create_ticket` with an invalid whitespace-only email, which likely causes Zendesk API errors or creates malformed tickets. Consider stripping whitespace before the empty check, or using a stricter validation that rejects whitespace-only input.

Evidence trail:
packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/plugins/create_ticket_with_notification.rb lines 32 and 102 at REVIEWED_COMMIT. Custom `present?` defined at line 102 checks `!value.nil? && value.to_s != ''` which returns true for whitespace-only strings. Line 32 uses this method to validate email before passing it to `create_ticket` at line 35.

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