Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module ForestAdminDatasourcePylon
# The closed sets `POST /issues` and `PATCH /issues/{id}` document, shared by
# the plugins building forms over them.
module IssueEnums
# Accepted on a create, and absent from every read: Pylon never returns the
# priority of an issue, which is why no column carries it.
PRIORITY = %w[urgent high medium low].freeze

# Where the first message of a created issue is delivered. `internal` is the
# absence of a delivery, and travels as no `destination_metadata` at all.
DESTINATION = %w[email slack in_app_chat customer_portal sms whatsapp internal].freeze

INTERNAL_DESTINATION = 'internal'.freeze

# The states Pylon ships. An organization defines its own on top of them, so
# this list is what a form offers, never what a write is checked against.
STANDARD_STATES = %w[new waiting_on_you waiting_on_customer on_hold closed].freeze

CLOSED_STATE = 'closed'.freeze
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
module ForestAdminDatasourcePylon
module Plugins
# Moves the selected issues to a state, `closed` unless told otherwise.
#
# One variant per scope, where the Zendesk plugin builds four: Zendesk has
# two terminal statuses to tell apart, Pylon has `closed` and, past it, the
# custom status slugs an organization defines — which the `state` option
# takes, rather than a second dimension of action names nobody would read.
#
# The state is written through the client rather than through the
# collection: the action is registered on the host collection, which is
# rarely PylonIssue, and going through a collection would mean resolving it
# from a datasource the plugin was not given.
class CloseIssue < ForestAdminDatasourceCustomizer::Plugins::Plugin
BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope
ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException

SCOPE_KEYS = %i[single bulk].freeze
SCOPES = { single: ActionScope::SINGLE, bulk: ActionScope::BULK }.freeze
NAMES = { single: 'Close Pylon issue', bulk: 'Close selected Pylon issues' }.freeze
NAME_OPTIONS = { single: :action_name, bulk: :bulk_action_name }.freeze

def run(_datasource_customizer, collection_customizer = nil, options = {})
opts = options.is_a?(Hash) ? options : {}
datasource = opts[:datasource]
raise ForestException, 'CloseIssue plugin requires :datasource' unless datasource
raise ForestException, 'CloseIssue plugin requires a collection' unless collection_customizer

state = normalize_state(opts[:state])

normalize_scopes(opts[:scopes]).each do |scope_key|
collection_customizer.add_action(name_for(scope_key, opts),
build_action(datasource, SCOPES[scope_key], state, opts[:issue_id_field]))
end
end

private

# Left unchecked against `STANDARD_STATES`: Pylon takes the slug of a
# custom status just as well, and refusing one would refuse the very
# workflow an organization built.
def normalize_state(value)
state = value.nil? ? IssueEnums::CLOSED_STATE : value.to_s
return state unless state.strip.empty?

raise ForestException, 'CloseIssue :state cannot be empty.'
end

def normalize_scopes(value)
scopes = Array(value).map(&:to_sym).uniq
scopes = SCOPE_KEYS if scopes.empty?
unknown = scopes - SCOPE_KEYS
return scopes if unknown.empty?

raise ForestException,
"Unknown CloseIssue scopes: #{unknown.join(", ")}. Allowed: #{SCOPE_KEYS.join(", ")}."
end

def name_for(scope_key, opts)
opts[NAME_OPTIONS[scope_key]] || NAMES[scope_key]
end

def build_action(datasource, scope, state, issue_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]

BaseAction.new(scope: scope, &executor(datasource, state, issue_id_field))
end

def executor(datasource, state, issue_id_field)
lambda do |context, result_builder|
ids = IssueTargets.resolve_issue_ids(context, issue_id_field)
next result_builder.error(message: Messages.no_target(issue_id_field)) if ids.empty?

succeeded, failed = apply_state(datasource, ids, state)
next result_builder.error(message: Messages.error(failed, state)) if succeeded.empty?

result_builder.success(message: Messages.success(succeeded, failed, state))
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): executor [qlty:function-complexity]

end

# One rescue per id: a single issue Pylon refuses — deleted, or outside
# the token's scope — must not cost the operator the rest of a selection,
# and what failed is named in the message rather than left to a log.
def apply_state(datasource, ids, state)
succeeded = []
failed = []

ids.each do |id|
datasource.client.update_issue(id, 'state' => state)
succeeded << id
rescue StandardError => e
ForestAdminDatasourcePylon.logger.warn(
"[forest_admin_datasource_pylon] failed to move issue #{id} to '#{state}': #{e.class}: #{e.message}"
)
failed << [id, "#{e.class}: #{e.message}"]
end

[succeeded, failed]
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module ForestAdminDatasourcePylon
module Plugins
class CloseIssue
# What the operator reads once the batch ran. Every id that failed is
# named: an action reporting a plain success over a batch it only half
# applied is the one thing the panel cannot recover from.
module Messages
module_function

def success(succeeded, failed, state)
[succeeded_phrase(succeeded, state), failed_phrase(failed)].compact.join(' ')
end

def error(failed, state)
return "Failed to #{verb(state)} issue #{failed.first.first}: #{failed.first.last}" if failed.size == 1

"Failed to #{verb(state)} all #{failed.size} issues. First error: #{failed.first.last}"
end

def no_target(field)
return 'No Pylon issue selected.' if field.nil?

"No Pylon issue id found in '#{field}'."
end

def succeeded_phrase(succeeded, state)
return nil if succeeded.empty?

return "Issue #{succeeded.first} #{past_verb(state)}." if succeeded.size == 1

"#{succeeded.size} issues #{past_verb(state)}."
end

def failed_phrase(failed)
return nil if failed.empty?

"#{failed.size} failed: #{failed.map(&:first).join(", ")}."
end

# A custom status is named as it is, where the state every organization
# has reads as the verb an operator used to fire the action.
def verb(state)
state == IssueEnums::CLOSED_STATE ? 'close' : "move to #{state}"
end

def past_verb(state)
state == IssueEnums::CLOSED_STATE ? 'closed' : "moved to #{state}"
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
module ForestAdminDatasourcePylon
module Plugins
# Opens a Pylon issue and delivers its first message to the requester.
#
# Pylon creates the contact on the fly from the form's email, so the action
# can be registered on any host collection — no relation to Pylon needed.
#
# Where Zendesk notifies as a side effect of a public comment, Pylon says it
# outright: `destination_metadata.destination` names the channel the
# issue's `body_html` is delivered through, and no `destination_metadata` at
# all is what leaves the issue internal. The "Send as internal note"
# checkbox is that choice, worded the way the Zendesk plugin words it.
#
# The form is FormBuilder's, the wire payload is Payload's; what is left
# here is the registration, its options, and what the operator reads back.
class CreateIssueWithNotification < ForestAdminDatasourceCustomizer::Plugins::Plugin
BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope
ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException

NAME = 'Create Pylon issue and notify'.freeze

def run(_datasource_customizer, collection_customizer = nil, options = {})
options = {} unless options.is_a?(Hash)
datasource = options[:datasource]
raise ForestException, 'CreateIssueWithNotification plugin requires :datasource' unless datasource
raise ForestException, 'CreateIssueWithNotification plugin requires a collection' unless collection_customizer

opts = options.except(:datasource)
opts[:email_templates] = Array(opts[:email_templates]).compact
opts[:destination] = normalize_destination(opts[:destination])
opts[:priority_override] = normalize_priority(opts[:priority_override])

collection_customizer.add_action(opts[:action_name] || NAME, build_action(datasource, opts))
end

private

def normalize_destination(value)
return Payload::EMAIL_DESTINATION if value.nil?

normalize(value, IssueEnums::DESTINATION, 'destination')
end

def normalize_priority(value)
return nil unless Payload.present?(value)

normalize(value, IssueEnums::PRIORITY, 'priority')
end

def normalize(value, allowed, label)
normalized = value.to_s
return normalized if allowed.include?(normalized)

raise ForestException,
"Unknown CreateIssueWithNotification #{label}: #{normalized}. Allowed: #{allowed.join(", ")}."
end

def build_action(datasource, opts)
BaseAction.new(scope: ActionScope::SINGLE, form: FormBuilder.build(opts), &executor(datasource, opts))
end

def executor(datasource, opts)
lambda do |context, result_builder|
values = context.form_values
email = values['Requester email']
next result_builder.error(message: 'Requester email is required.') unless Payload.present?(email)

issue = datasource.client.create_issue(Payload.build(values, email, opts))
writeback = write_back_issue_id(context, opts[:issue_id_field], issue['id'])
result_builder.success(message: success_message(issue, values, opts, writeback))
end
end

# Best-effort: Pylon has no transaction to roll back, and the issue exists
# whether or not the host record could be stamped with its id.
def write_back_issue_id(context, field, issue_id)
return :skipped if field.nil?

context.collection.update(context.filter, { field => issue_id })
:ok
rescue StandardError => e
ForestAdminDatasourcePylon.logger.warn(
"[forest_admin_datasource_pylon] failed to store the issue id in '#{field}': #{e.class}: #{e.message}"
)
[:failed, "#{e.class}: #{e.message}"]
end

def success_message(issue, values, opts, writeback)

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

base = base_success_message(issue, values, opts)
return base unless writeback.is_a?(Array) && writeback.first == :failed

"#{base} (warning: could not store the issue id on the record: #{writeback.last})"
end

# The number is what an operator recognises an issue by; the id stands in
# when Pylon answered without one.
def base_success_message(issue, values, opts)
reference = issue['number'] || issue['id']
destination = Payload.destination_for(values, opts)
if Payload.internal?(destination)
return "Issue ##{reference} created (internal, the requester was not contacted)."
end

"Issue ##{reference} created and the requester notified by #{destination.tr("_", " ")}."
end
end
end
end
Loading
Loading