-
Notifications
You must be signed in to change notification settings - Fork 1
feat(zendesk): add plugins for creating tickets and closing actions #303
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b95c090
57f68ff
fc9076e
a731e0a
49af998
27a8df1
21ceb62
a8ec9b6
8aca51b
9c324d3
8f6540d
42789c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| module ForestAdminDatasourceZendesk | ||
| module Plugins | ||
| # The Zendesk ticket id is read from a configurable column on the host | ||
| # record(s); Zendesk sometimes rejects the direct `open -> closed` | ||
| # transition so failures are surfaced per-id rather than retried. | ||
| class CloseTicket < ForestAdminDatasourceCustomizer::Plugins::Plugin | ||
| BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction | ||
| ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope | ||
| ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException | ||
|
|
||
| STATUSES = %w[solved closed].freeze | ||
| SCOPE_KEYS = %i[single bulk].freeze | ||
|
|
||
| NAMES = { | ||
| 'solved' => { single: 'Mark Zendesk ticket as solved', | ||
| bulk: 'Mark selected Zendesk tickets as solved' }.freeze, | ||
| 'closed' => { single: 'Mark Zendesk ticket as closed', | ||
| bulk: 'Mark selected Zendesk tickets as closed' }.freeze | ||
| }.freeze | ||
|
|
||
| SCOPES = { single: ActionScope::SINGLE, bulk: ActionScope::BULK }.freeze | ||
|
|
||
| def run(_datasource_customizer, collection_customizer = nil, options = {}) | ||
| datasource = options[:datasource] | ||
| ticket_id_field = options[:ticket_id_field] | ||
| raise ForestException, 'CloseTicket plugin requires :datasource' unless datasource | ||
| raise ForestException, 'CloseTicket plugin requires :ticket_id_field' unless ticket_id_field | ||
| raise ForestException, 'CloseTicket plugin requires a collection' unless collection_customizer | ||
|
|
||
| statuses = normalize_statuses(options[:statuses]) | ||
| scopes = normalize_scopes(options[:scopes]) | ||
|
|
||
| variants(statuses, scopes).each do |name, status, scope| | ||
| collection_customizer.add_action(name, build_action(datasource, status, scope, ticket_id_field)) | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def normalize_statuses(value) | ||
| normalize(value, :to_s, STATUSES, 'statuses') | ||
| end | ||
|
|
||
| def normalize_scopes(value) | ||
| normalize(value, :to_sym, SCOPE_KEYS, 'scopes') | ||
| end | ||
|
|
||
| def normalize(value, cast, allowed, label) | ||
| list = Array(value).map(&cast).uniq | ||
| list = allowed if list.empty? | ||
| unknown = list - allowed | ||
| return list if unknown.empty? | ||
|
|
||
| raise ForestException, | ||
| "Unknown CloseTicket #{label}: #{unknown.join(", ")}. Allowed: #{allowed.join(", ")}." | ||
| end | ||
|
|
||
| def variants(statuses, scopes) | ||
| statuses.flat_map do |status| | ||
| scopes.map { |scope_key| [NAMES[status][scope_key], status, SCOPES[scope_key]] } | ||
| end | ||
| end | ||
|
|
||
| def build_action(datasource, status, scope, ticket_id_field) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| BaseAction.new(scope: scope, &executor(datasource, status, ticket_id_field)) | ||
| end | ||
|
|
||
| def executor(datasource, status, ticket_id_field) | ||
| lambda do |context, result_builder| | ||
| ids = resolve_ticket_ids(context, ticket_id_field) | ||
| next result_builder.error(message: "No Zendesk ticket id found in '#{ticket_id_field}'.") if ids.empty? | ||
|
|
||
| succeeded, already_closed, failed = apply_status(datasource, ids, status) | ||
|
|
||
| # Closed tickets can't be reopened to 'solved'; fold into failures. | ||
| if status == 'solved' | ||
| failed += already_closed.map { |id| [id, 'ticket is already closed (cannot reopen to mark as solved)'] } | ||
| already_closed = [] | ||
| end | ||
|
|
||
| if succeeded.empty? && already_closed.empty? | ||
| result_builder.error(message: Messages.error(failed, status)) | ||
| else | ||
| result_builder.success(message: Messages.success(succeeded, already_closed, failed, status)) | ||
| end | ||
| end | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| def resolve_ticket_ids(context, ticket_id_field) | ||
| records = context.get_records([ticket_id_field]) | ||
| records.filter_map { |r| r[ticket_id_field.to_s] } | ||
| rescue StandardError => e | ||
| ForestAdminDatasourceZendesk.logger.warn( | ||
| "[forest_admin_datasource_zendesk] failed to resolve ticket ids from '#{ticket_id_field}': " \ | ||
| "#{e.class}: #{e.message}" | ||
| ) | ||
| [] | ||
| end | ||
|
|
||
| # Per-id rescue so a single transition rejection doesn't abort bulk. | ||
| def apply_status(datasource, ids, status) | ||
| succeeded = [] | ||
| already_closed = [] | ||
| failed = [] | ||
| ids.each do |id| | ||
| datasource.client.update_ticket(id, 'status' => status) | ||
| succeeded << id | ||
| rescue StandardError => e | ||
| if Errors.already_closed?(e) | ||
| already_closed << id | ||
| else | ||
| ForestAdminDatasourceZendesk.logger.warn( | ||
| "[forest_admin_datasource_zendesk] failed to set ticket ##{id} to '#{status}': " \ | ||
| "#{e.class}: #{e.message}" | ||
| ) | ||
| failed << [id, "#{e.class}: #{e.message}"] | ||
| end | ||
| end | ||
| [succeeded, already_closed, failed] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| module ForestAdminDatasourceZendesk | ||
| module Plugins | ||
| class CloseTicket | ||
| # Decoding helpers for Zendesk's structured update-error payloads. | ||
| module Errors | ||
| module_function | ||
|
|
||
| # Zendesk refuses any update on a closed ticket with this exact | ||
| # wording on the `status` field — detected so we can swap the raw | ||
| # stack for a clean message. | ||
| ALREADY_CLOSED_DESCRIPTION = 'closed prevents ticket update'.freeze | ||
|
|
||
| def already_closed?(error) | ||
| invalid = unwrap_record_invalid(error) | ||
| return false unless invalid | ||
|
|
||
| status_errors = invalid.errors.is_a?(Hash) ? Array(invalid.errors['status']) : [] | ||
| status_errors.any? do |entry| | ||
| entry.is_a?(Hash) && entry['description'].to_s.include?(ALREADY_CLOSED_DESCRIPTION) | ||
| end | ||
| end | ||
|
|
||
| def unwrap_record_invalid(error) | ||
| while error | ||
| return error if error.is_a?(ZendeskAPI::Error::RecordInvalid) | ||
|
|
||
| error = error.cause | ||
| end | ||
| nil | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| module ForestAdminDatasourceZendesk | ||
| module Plugins | ||
| class CloseTicket | ||
| module Messages | ||
| module_function | ||
|
|
||
| def success(succeeded, already_closed, failed, status) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| [succeeded_phrase(succeeded, status), already_closed_phrase(already_closed), | ||
| failed_phrase(failed)].compact.join(' ') | ||
| end | ||
|
|
||
| def error(failed, status) | ||
| verb = status == 'closed' ? 'close' : 'mark as solved' | ||
| return "Failed to #{verb} ticket ##{failed.first.first}: #{failed.first.last}" if failed.size == 1 | ||
|
|
||
| "Failed to #{verb} all #{failed.size} tickets. First error: #{failed.first.last}" | ||
| end | ||
|
|
||
| def succeeded_phrase(succeeded, status) | ||
| return nil if succeeded.empty? | ||
|
|
||
| verb = status == 'closed' ? 'closed' : 'marked as solved' | ||
| succeeded.size == 1 ? "Ticket ##{succeeded.first} #{verb}." : "#{succeeded.size} tickets #{verb}." | ||
| end | ||
|
|
||
| def already_closed_phrase(already_closed) | ||
| return nil if already_closed.empty? | ||
| return "Ticket ##{already_closed.first} was already closed." if already_closed.size == 1 | ||
|
|
||
| "#{already_closed.size} tickets were already closed: #{already_closed.join(", ")}." | ||
| end | ||
|
|
||
| def failed_phrase(failed) | ||
| return nil if failed.empty? | ||
|
|
||
| "#{failed.size} failed: #{failed.map(&:first).join(", ")}." | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
There was a problem hiding this comment.
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]