feat(datasource-pylon): close and create-with-notification action plugins (EXT-12) - #366
feat(datasource-pylon): close and create-with-notification action plugins (EXT-12)#366christophebrun-forest wants to merge 1 commit into
Conversation
Two plugins at parity with the Ruby Zendesk ones, built on the write primitives of the previous commit. CloseIssue moves the selected issues to a state, `closed` unless told otherwise -- a custom status slug is taken as readily, Pylon accepting one wherever it accepts a standard state. One variant per scope rather than the four Zendesk builds, there being a single terminal state here. Each id is written under its own rescue and named in the message, so a batch that only half applied never reads as a plain success. CreateIssueWithNotification opens an issue and delivers its first message. Pylon says the delivery outright where Zendesk infers it from a public comment: `destination_metadata.destination` names the channel, and no metadata at all is what leaves the issue internal, which is what the "send as internal note" checkbox writes. The form carries no Type field, `POST /issues` taking none, and the priority it does carry is never read back -- no Pylon read returns one. Both find the issues to act on through one option: `issue_id_field` names a column of the host collection, and its absence falls back to the primary keys, which is what an action registered on PylonIssue acts on. Snooze is left out until its endpoint is confirmed against a live organization; IssueTargets and the messages module are already shared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7 new issues
|
| opts[NAME_OPTIONS[scope_key]] || NAMES[scope_key] | ||
| end | ||
|
|
||
| def build_action(datasource, scope, state, issue_id_field) |
| next result_builder.error(message: Messages.error(failed, state)) if succeeded.empty? | ||
|
|
||
| result_builder.success(message: Messages.success(succeeded, failed, state)) | ||
| end |
| [:failed, "#{e.class}: #{e.message}"] | ||
| end | ||
|
|
||
| def success_message(issue, values, opts, writeback) |
| "[forest_admin_datasource_pylon] 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 |
| "[forest_admin_datasource_pylon] failed to resolve the issues to act on #{source}: " \ | ||
| "#{e.class}: #{e.message}" | ||
| ) | ||
| [] |
| end | ||
|
|
||
| def fetch_record(context) | ||
| context.get_record([]) || {} |
There was a problem hiding this comment.
🟡 Medium create_issue_with_notification/form_builder.rb:126
fetch_record requests an empty projection, so production ActionContext#get_record returns no record fields and every {{ record.field }} interpolation resolves to ''; a proc-based requester_email_default likewise receives an empty record and resolves incorrectly. The fake context hides this by ignoring its fields argument. Extract the referenced fields and pass a populated projection to get_record (or otherwise request the full record).
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb around line 126:
`fetch_record` requests an empty projection, so production `ActionContext#get_record` returns no record fields and every `{{ record.field }}` interpolation resolves to `''`; a proc-based `requester_email_default` likewise receives an empty record and resolves incorrectly. The fake context hides this by ignoring its `fields` argument. Extract the referenced fields and pass a populated projection to `get_record` (or otherwise request the full record).
| # `value:` (not `default_value:`) — drop_default runs once (data | ||
| # key sticks after the first render); drop_deferred re-evaluates | ||
| # on every fetch, so Template changes re-fire the message proc. | ||
| field.merge(value: message_value(templates)) |
There was a problem hiding this comment.
🟡 Medium create_issue_with_notification/form_builder.rb:68
When email_templates is non-empty, message_field omits the configured default_message, so the form opens with a blank required Message because Template initially equals NO_TEMPLATE; selecting NO_TEMPLATE later also replaces the message with ''. Preserve the escaped default_message as default_value and have message_value return that value for NO_TEMPLATE, while keeping deferred template interpolation for other selections.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb around line 68:
When `email_templates` is non-empty, `message_field` omits the configured `default_message`, so the form opens with a blank required `Message` because `Template` initially equals `NO_TEMPLATE`; selecting `NO_TEMPLATE` later also replaces the message with `''`. Preserve the escaped `default_message` as `default_value` and have `message_value` return that value for `NO_TEMPLATE`, while keeping deferred template interpolation for other selections.
Part of EXT-12 — Story 8 of EXT-4.
Two of the three plugins the story scopes.
SnoozeIssue—POST /issues/{id}/snooze, the Pylon bonus with no Zendesk equivalent — is not in here, and the client exposes no snooze endpoint yet, so EXT-12 stays open.What
issue_enums.rbPOST /issuesandPATCH /issues/{id}document, shared by the two formsplugins/issue_targets.rbplugins/close_issue.rb+close_issue/messages.rbplugins/create_issue_with_notification.rbcreate_issue_with_notification/form_builder.rb{{ record.field }}interpolationcreate_issue_with_notification/payload.rbBoth are subclasses of
ForestAdminDatasourceCustomizer::Plugins::Plugin, registered by the host project throughuse, on any collection — no relation to Pylon is needed, Pylon creating the contact on the fly from the form's email.The write goes through the client, not through the collection
An action is registered on the host collection, which is rarely
PylonIssue, and going through a Pylon collection would mean resolving it from a datasource the plugin was not given. So both plugins callClient::Writesdirectly, and none of the guards EXT-11 built on the collection path applies here: no writable-column filter, no create/update direction, noMAX_WRITE_REQUESTS, and noWriteRejectedError.That last one is worth a decision rather than an omission, because it is what the operator sees when Pylon refuses:
CloseIssuehandles it. One rescue per id, and the failed ids are named in the action's own result message withe.message— which isAPIError's, carryingHTTP 4xx <Pylon's reason> (request_id: …). A single issue Pylon refuses (deleted, outside the token's scope, a status slug it does not know) costs neither the batch nor the reason. This is the callerAPIErrorwas documented for.CreateIssueWithNotificationdoes not.client.create_issueis unrescued, so anAPIErrorleaves the executor and reachesErrorTranslator, which recognises neitherHttpException,BusinessErrornorForestExceptionand answers'Unexpected error'with the status. The likeliest way this action fails — a required field Pylon wants, a value the endpoint refuses — therefore arrives with nothing in it. This is the one thing in the PR I would fix before merge, and the shape is already there: either theresult_builder.errorthatCloseIssueuses per id, orsurface_write_rejection's rule of translating the 4xx and leaving a 5xx alone.Decisions worth reviewing
A state is not checked against the list Pylon ships.
STANDARD_STATESis what a form offers, never what a write is checked against: an organization defines its own statuses on top of the five built-in ones, and refusing a slug would refuse the very workflow they built.:statetherefore takes any non-empty string, andclosedis the default.One variant per scope, where the Zendesk plugin builds four. Zendesk has two terminal statuses to tell apart; Pylon has
closedand, past it, the custom slugs — which the:stateoption takes, rather than a second dimension of action names nobody would read.Messageswords a custom slug as "move to X" and the universal state as "close", so the action reads as the verb the operator fired.An internal issue travels as no metadata at all. Where Zendesk notifies as a side effect of a public comment, Pylon says it outright:
destination_metadata.destinationnames the channelbody_htmlis delivered through, and its absence is what leaves the issue internal — the form the reference names for "do not contact the requester", and the one that stays right if Pylon adds a required companion field to a real destination. The "Send as internal note" checkbox is that choice, and it wins over the configured destination: it is the operator's call, made on the record they are looking at.The Message field is
value:, notdefault_value:, as soon as templates are configured.drop_defaultruns once — the data key sticks after the first render — wheredrop_deferredre-evaluates on every fetch, which is what re-fires the message proc when Template changes. The proc returnsnilunlessfield_changed?('Template'), soset_watch_changescarries the operator's own edits across renders instead of overwriting them. The two-page wizard exists for the same reason, and stays homogeneous:ActionCollectionDecoratorrejects a form mixing Page elements with non-Page ones.{{ record.field }}is HTML-escaped into the message and not into the subject. The message ships asbody_htmland is delivered as such, so an unescaped<or&from a record value would break the outbound message or smuggle markup into it. The subject and the requester email are not HTML and are interpolated raw. A token whose field is nil interpolates to nothing rather than to"nil".No Type field on the form.
POST /issuesdoes not take one — Pylon acceptstypeon an update only, which is whatIssue::UPDATE_ONLYalready says. No Priority default either, and none read back: Pylon never returns the priority of an issue, which is why no column carries it and why the field says so.Nothing in the target resolution raises.
IssueTargetsanswers "no issue selected" through the action's message when a column was renamed or a record the scope hides is selected, rather than through a stack trace in the panel. Same for the form: arequester_email_defaultresolver that raises, or a record that cannot be fetched for interpolation, degrades to no prefill and a warning.The id writeback is 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, so a failed writeback is reported as a warning appended to the success message rather than as a failure of the action.
Verification
701 examples, 0 failures— coverage 1551/1551 lines (100%, threshold 90), branch 94.3%. RuboCop clean over the 64 files of the package.Plugin specs cover: the option validation of both plugins; the scopes built and their names overridden; a batch where one id fails and the rest land, with the failed ones named; no target resolved from a renamed column; the payload of each destination, the internal note winning over the configured one, and the email metadata only on an email delivery; the two form shapes, the template re-firing the message on a Template change and not otherwise, the token interpolation escaped into the message and raw into the subject, and a nil field interpolating to nothing; the writeback skipped, done, and failed.
Rebased onto
feat/datasource-pylonafter EXT-11 landed: the plugins touch no file of the write path, so the rebase carried no conflict.Note
Add
CloseIssueandCreateIssueWithNotificationplugins to Pylon datasourceCloseIssueplugin, which registers single and bulk actions to update Pylon issue states. It resolves target IDs viaIssueTargetsand handles errors per-ID so partial failures do not abort bulk operations.CreateIssueWithNotificationplugin, which registers a single action to create issues and notify requesters. It usesFormBuilderfor dynamic forms with record-based token interpolation andPayloadto format API requests.IssueEnumsfor validating options,Messagesfor operator feedback, andIssueTargetsfor extracting issue IDs from records.CloseIssue.apply_statecatches errors per-ID, so partial failures happen without aborting;CreateIssueWithNotification.write_back_issue_idlogs warnings on failure but proceeds with the action.📊 Macroscope summarized 6598012. 7 files reviewed, 9 issues evaluated, 7 issues filtered, 2 comments posted
🗂️ Filtered Issues
packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb — 2 comments posted, 9 evaluated, 7 filtered
template_fieldreserves the visible valueNo templatebut also appends template titles without rejecting that same title. If anemail_templatesentry is titledNo template, selecting it is indistinguishable from the sentinel andmessage_valuereturns''instead of its content, making that configured template unusable. [ Out of scope (post-validation triage) ]by_title = templates.to_hsilently collapses templates that share a title, whiletemplate_fieldstill exposes both duplicate labels. With two configured templates named alike, either selection resolves to the last template's content, so the earlier template can never be selected. Validate title uniqueness or use unique option values. [ Out of scope ]templates.to_hcollapses templates that share a title, while the enum still exposes both duplicate labels. Selecting either duplicate therefore always inserts the last template's content, so earlier templates with the same title are silently unusable. The plugin performs no uniqueness validation onemail_templates. [ Out of scope (post-validation triage) ]message_valueindexes templates only by their unvalidated:title, whileNo templateis also used as a reserved sentinel. If two configured templates share a title,to_hsilently keeps only the last one's content; if a template is titledNo template, selecting it takes the sentinel branch and clears the Message instead of loading its content. Template titles should be validated as unique and distinct fromNO_TEMPLATE(or selection should use non-colliding IDs). [ Out of scope (post-validation triage) ]message_valuetreats every selection titledNo templateas the sentinel, buttemplate_fieldalso permits an email template with that exact title and no validation reserves it. Configuring such a template makes it impossible to select: choosing it always returns''instead of its content (and also creates duplicate enum values). [ Out of scope (post-validation triage) ]"No template"is added to the enum, butmessage_valuealways treats that selected title as the sentinel and returns an empty message at line 116. Since template titles are not validated or reserved by the plugin, that legitimate template can never be selected and its content is silently discarded. [ Out of scope (post-validation triage) ]message_valuetreats the display label"No template"as a reserved sentinel, but template titles are not validated against it. If a configured template has that title, the enum contains two indistinguishable entries and selecting either always returns'', making that template impossible to use. Reject/reserve this title or use a distinct internal enum value. [ Out of scope (post-validation triage) ]