diff --git a/app/models/pager_tree/integrations/custom_webhook/v3.rb b/app/models/pager_tree/integrations/custom_webhook/v3.rb index 1e233cf..f911f73 100644 --- a/app/models/pager_tree/integrations/custom_webhook/v3.rb +++ b/app/models/pager_tree/integrations/custom_webhook/v3.rb @@ -37,6 +37,8 @@ def adapter_action case custom_response_result.dig("type")&.downcase when "create" :create + when "update" + :update when "acknowledge" :acknowledge when "resolve" @@ -62,12 +64,46 @@ def adapter_process_create ) end + # like adapter_process_create, but title/description/incident fall back to + # nil instead of a placeholder, so unset fields don't overwrite the existing alert - + # and no thirdparty_id, since update must never change which alert this is + def adapter_process_update + Alert.new( + title: _update_title, + description: _update_description, + urgency: _urgency, + dedup_keys: _dedup_keys, + incident_severity: _incident_severity, + incident_message: _incident_message, + tags: _tags, + meta: _update_meta, + additional_data: _additional_datums + ) + end + def custom_response_processed_log_data _custom_response_processed_log_data || {} end private + def _update_title + custom_response_result.dig("title")&.to_s&.presence + end + + def _update_description + custom_response_result.dig("description")&.to_s&.presence + end + + # PagerTree::Integrations::Alert#incident always defaults to false, so + # "incident" is carried in meta instead - the only way to tell "not provided" + # (leave alert.incident untouched) apart from "explicitly false" + # (_incident is already nil when "incident" is absent - ActiveModel::Type::Boolean casts nil to nil) + def _update_meta + incident = _incident + incident.nil? ? _meta : _meta.merge("incident" => incident) + end + def _custom_response return {} unless adapter_incoming_deferred_request.present? diff --git a/app/models/pager_tree/integrations/email/v3.rb b/app/models/pager_tree/integrations/email/v3.rb index 55ebfab..bd6887f 100644 --- a/app/models/pager_tree/integrations/email/v3.rb +++ b/app/models/pager_tree/integrations/email/v3.rb @@ -88,6 +88,8 @@ def adapter_action case custom_response_result.dig("type")&.downcase when "create" :create + when "update" + :update when "acknowledge" :acknowledge when "resolve" @@ -129,8 +131,74 @@ def adapter_process_create end end + # only reachable via the custom template (adapter_action only returns :update + # there) - fields the rule doesn't return come back nil/empty so an update + # payload can't clobber alert data it didn't mean to touch, and there's no + # thirdparty_id, since update must never change which alert this is + def adapter_process_update + Alert.new( + title: _update_title, + description: _update_description, + urgency: _update_urgency, + dedup_keys: _update_dedup_keys, + incident_severity: _incident_severity, + incident_message: _incident_message, + tags: _tags, + meta: _update_meta, + additional_data: _update_additional_datums + ) + end + private + def _update_title + custom_response_result.dig("title")&.to_s&.presence + end + + def _update_description + custom_response_result.dig("description")&.to_s&.presence + end + + # unlike _urgency, doesn't fall back to the integration's default urgency + def _update_urgency + custom_response_result.dig("urgency")&.to_s&.presence + end + + # unlike _dedup_keys, doesn't fall back to thread references - those were + # already applied when the alert was created + def _update_dedup_keys + Array(custom_response_result.dig("dedup_keys")).compact_blank.map(&:to_s).uniq.map { |x| "#{prefix_id}_#{x}" } + end + + # PagerTree::Integrations::Alert#incident always defaults to false, so + # "incident" is carried in meta instead - the only way to tell "not provided" + # (leave alert.incident untouched) apart from "explicitly false" + # (_incident is already nil when "incident" is absent - ActiveModel::Type::Boolean casts nil to nil) + def _update_meta + incident = _incident + incident.nil? ? _meta : _meta.merge("incident" => incident) + end + + # unlike _additional_datums, doesn't fall back to the From/To/CCs default - + # those were already applied when the alert was created + def _update_additional_datums + items = custom_response_result.dig("additional_data") || [] + items = [items] unless items.is_a?(Array) + + items.each_with_object([]) do |ad, result| + next unless ad.is_a?(Hash) + + format = ad["format"].to_s + next unless PagerTree::Integrations::AdditionalDatum::FORMATS.include?(format) + + result << AdditionalDatum.new( + format: format, + label: ad["label"].to_s.presence || "Untitled", + value: ad["value"] + ) + end + end + def _custom_response return @_custom_response ||= {} unless custom_definition? diff --git a/app/models/pager_tree/integrations/integration.rb b/app/models/pager_tree/integrations/integration.rb index 84a5446..6c338ab 100644 --- a/app/models/pager_tree/integrations/integration.rb +++ b/app/models/pager_tree/integrations/integration.rb @@ -59,6 +59,9 @@ def adapter_action def adapter_process_create end + def adapter_process_update + end + def adapter_process_other end diff --git a/app/models/pager_tree/integrations/webhook/v3.rb b/app/models/pager_tree/integrations/webhook/v3.rb index cf6982f..dbd7be8 100644 --- a/app/models/pager_tree/integrations/webhook/v3.rb +++ b/app/models/pager_tree/integrations/webhook/v3.rb @@ -39,6 +39,8 @@ def adapter_action case _adapter_incoming_request_params.dig("event_type").to_s.downcase.strip when "create" :create + when "update" + :update when "acknowledge" :acknowledge when "resolve" @@ -64,8 +66,39 @@ def adapter_process_create ) end + # Like adapter_process_create, but omits thirdparty_id and does not apply adapter-level + # fallbacks (so missing scalar fields remain nil). Note: array fields still default to + # [] in PagerTree::Integrations::Alert. + def adapter_process_update + Alert.new( + title: _title, + description: _description, + urgency: _urgency, + dedup_keys: _dedup_keys, + incident_severity: _incident_severity, + incident_message: _incident_message, + tags: _tags, + meta: _update_meta, + additional_data: _additional_datums + ) + end + private + # PagerTree::Integrations::Alert#incident always defaults to false, so + # "incident" is carried in meta instead - the only way to tell "not provided" + # (leave alert.incident untouched) apart from "explicitly false" + def _incident_update + meta = _adapter_incoming_request_params.dig("meta") + return nil unless meta.is_a?(Hash) && meta.key?("incident") + ActiveModel::Type::Boolean.new.cast(meta["incident"]) + end + + def _update_meta + incident = _incident_update + incident.nil? ? _meta : _meta.merge("incident" => incident) + end + def _title _adapter_incoming_request_params.dig("title") end diff --git a/test/models/pager_tree/integrations/custom_webhook/v3_test.rb b/test/models/pager_tree/integrations/custom_webhook/v3_test.rb index 247c49e..0d8fafe 100644 --- a/test/models/pager_tree/integrations/custom_webhook/v3_test.rb +++ b/test/models/pager_tree/integrations/custom_webhook/v3_test.rb @@ -1,4 +1,5 @@ require "test_helper" +require "ostruct" module PagerTree::Integrations class CustomWebhook::V3Test < ActiveSupport::TestCase @@ -36,6 +37,13 @@ class CustomWebhook::V3Test < ActiveSupport::TestCase label: Triggered At value: "{{log.data.alertDateTime}}" + - match: + log.data.alertTypeFriendlyName: { $regex: "^degraded$", $options: "i" } + actions: + - type: update + urgency: "{{log.data.urgency}}" + thirdparty_id: "{{log.data.monitorID}}" + - match: log.data.alertTypeFriendlyName: { $regex: "^pending$", $options: "i" } actions: @@ -63,6 +71,9 @@ class CustomWebhook::V3Test < ActiveSupport::TestCase "alertDateTime" => 1733126400 }.with_indifferent_access + @update_request = @create_request.deep_dup + @update_request["alertTypeFriendlyName"] = "Degraded" + @acknowledge_request = @create_request.deep_dup @acknowledge_request["alertTypeFriendlyName"] = "Pending" @@ -111,6 +122,13 @@ class CustomWebhook::V3Test < ActiveSupport::TestCase assert_equal :acknowledge, @integration.adapter_action end end + test "adapter_action_update" do + VCR.use_cassette("custom_webhook_v3_adapter_action_update") do + @integration.adapter_incoming_request_params = @update_request + @integration.adapter_incoming_deferred_request.body = @update_request + assert_equal :update, @integration.adapter_action + end + end test "adapter_action_resolve" do VCR.use_cassette("custom_webhook_v3_adapter_action_resolve") do @integration.adapter_incoming_request_params = @resolve_request @@ -163,5 +181,22 @@ class CustomWebhook::V3Test < ActiveSupport::TestCase assert_equal true_alert.as_json, @integration.adapter_process_create.as_json end end + + test "adapter_process_update only carries fields the service actually returned" do + VCR.use_cassette("custom_webhook_v3_adapter_process_update") do + @integration.adapter_incoming_request_params = @update_request + + true_alert = Alert.new( + urgency: "high", + meta: { + "incident" => false + } + ) + + result = @integration.adapter_process_update + assert_equal true_alert.as_json, result.as_json + assert_nil result.thirdparty_id + end + end end end diff --git a/test/models/pager_tree/integrations/email/v3_test.rb b/test/models/pager_tree/integrations/email/v3_test.rb index 8b9cba9..b2dab25 100644 --- a/test/models/pager_tree/integrations/email/v3_test.rb +++ b/test/models/pager_tree/integrations/email/v3_test.rb @@ -42,6 +42,7 @@ class Email::V3Test < ActiveSupport::TestCase message_id "down-98765@server.com" end + @update_mail = @down_mail.dup.tap { |m| m.subject = "Server is DEGRADED" } @ack_mail = @down_mail.dup.tap { |m| m.subject = "Server is PENDING maintenance" } @resolve_mail = @down_mail.dup.tap { |m| m.subject = "Server is UP again" } @other_mail = @down_mail.dup.tap { |m| m.subject = "Paused" } @@ -107,6 +108,15 @@ class Email::V3Test < ActiveSupport::TestCase end end + test "adapter_action_update with custom definition" do + setup_custom_definition + + VCR.use_cassette("email_v3_custom_adapter_action_update") do + @integration.adapter_incoming_request_params = {"mail" => @update_mail} + assert_equal :update, @integration.adapter_action + end + end + test "adapter_action_acknowledge with custom definition" do setup_custom_definition @@ -182,6 +192,23 @@ class Email::V3Test < ActiveSupport::TestCase end end + test "adapter_process_update only carries fields the rule actually returned" do + setup_custom_definition + + VCR.use_cassette("email_v3_custom_adapter_process_update") do + @integration.adapter_incoming_request_params = {"mail" => @update_mail} + + expected_alert = Alert.new( + urgency: "high", + meta: {"incident" => false} + ) + + result = @integration.adapter_process_update + assert_equal expected_alert.as_json, result.as_json + assert_nil result.thirdparty_id + end + end + private def setup_custom_definition @@ -209,6 +236,13 @@ def setup_custom_definition label: From value: "{{log.from}}" + - match: + log.subject: { $regex: "degraded", $options: "i" } + actions: + - type: update + urgency: "high" + thirdparty_id: "email-test-123" + - match: log.subject: { $regex: "pending", $options: "i" } actions: diff --git a/test/models/pager_tree/integrations/webhook/v3_test.rb b/test/models/pager_tree/integrations/webhook/v3_test.rb index b9bc4c0..aa0611d 100644 --- a/test/models/pager_tree/integrations/webhook/v3_test.rb +++ b/test/models/pager_tree/integrations/webhook/v3_test.rb @@ -1,4 +1,5 @@ require "test_helper" +require "ostruct" module PagerTree::Integrations class Webhook::V3Test < ActiveSupport::TestCase @@ -28,6 +29,9 @@ class Webhook::V3Test < ActiveSupport::TestCase dedup_keys: ["group_my_whole_account"] }.with_indifferent_access + @update_request = @create_request.deep_dup + @update_request[:event_type] = "update" + @acknowledge_request = @create_request.deep_dup @acknowledge_request[:event_type] = "acknowledge" @@ -51,6 +55,9 @@ class Webhook::V3Test < ActiveSupport::TestCase @integration.adapter_incoming_request_params = @create_request assert_equal :create, @integration.adapter_action + @integration.adapter_incoming_request_params = @update_request + assert_equal :update, @integration.adapter_action + @integration.adapter_incoming_request_params = @acknowledge_request assert_equal :acknowledge, @integration.adapter_action @@ -109,6 +116,56 @@ class Webhook::V3Test < ActiveSupport::TestCase assert_equal true_alert.as_json, @integration.adapter_process_create.as_json end + test "adapter_process_update" do + @integration.adapter_incoming_request_params = @update_request + + true_alert = Alert.new( + title: @update_request.dig(:Title), + description: @update_request.dig(:Description), + urgency: @update_request.dig(:Urgency).downcase, + dedup_keys: @update_request.dig(:dedup_keys), + incident_severity: @update_request.dig(:Meta, :incident_severity).upcase, + incident_message: @update_request.dig(:Meta, :incident_message), + tags: @update_request.dig(:Tags).uniq, + meta: @update_request.dig(:Meta).except(:incident_severity, :incident_message) + ) + + assert_equal true_alert.as_json, @integration.adapter_process_update.as_json + end + + test "adapter_process_update omits fields left out of the payload" do + @integration.adapter_incoming_request_params = { + event_type: "update", + Id: "example-id-123" + }.with_indifferent_access + + integration_alert = @integration.adapter_process_update + + assert_nil integration_alert.title + assert_nil integration_alert.description + assert_nil integration_alert.urgency + assert_nil integration_alert.incident_severity + assert_nil integration_alert.incident_message + assert_equal [], integration_alert.tags + assert_equal [], integration_alert.dedup_keys + assert_not integration_alert.meta.key?("incident") + end + + test "adapter_process_update never carries thirdparty_id" do + @integration.adapter_incoming_request_params = @update_request + + assert_nil @integration.adapter_process_update.thirdparty_id + end + + test "adapter_process_update carries an explicitly provided incident flag in meta" do + @update_request[:Meta][:incident] = false + @integration.adapter_incoming_request_params = @update_request + + integration_alert = @integration.adapter_process_update + + assert_equal false, integration_alert.meta["incident"] + end + test "blocking_incoming" do @blocked_request = @create_request.deep_dup @integration.option_token = "abc123" diff --git a/test/vcr_cassettes/custom_webhook_v3_adapter_action_update.yml b/test/vcr_cassettes/custom_webhook_v3_adapter_action_update.yml new file mode 100644 index 0000000..9689332 --- /dev/null +++ b/test/vcr_cassettes/custom_webhook_v3_adapter_action_update.yml @@ -0,0 +1,50 @@ +--- +http_interactions: +- request: + method: post + uri: "" + body: + encoding: UTF-8 + string: '{"log":{"remote_ip":"127.0.0.1","url":"https://integration.url/int_123","method":"post","headers":{"User-Agent":"UptimeRobot/1.0","Content-Type":"application/json"},"data":{"monitorID":"12345","alertTypeFriendlyName":"Degraded","monitorFriendlyName":"My + Website","alertDetails":"Connection timeout","monitorURL":"https://example.com","urgency":"high","alertDateTime":1733126400},"params":null,"body":{"monitorID":"12345","alertTypeFriendlyName":"Degraded","monitorFriendlyName":"My + Website","alertDetails":"Connection timeout","monitorURL":"https://example.com","urgency":"high","alertDateTime":1733126400}},"config":{"rules":[{"match":{"log.data.alertTypeFriendlyName":{"$regex":"^degraded$","$options":"i"}},"actions":[{"type":"update","urgency":"{{log.data.urgency}}","thirdparty_id":"{{log.data.monitorID}}"}]}]}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + X-Powered-By: + - Express + Content-Type: + - application/json; charset=utf-8 + Etag: + - W/"223-aAaPHh/aQ2lRQrKECMXb4swg5io" + Date: + - Fri, 05 Dec 2025 17:07:11 GMT + Connection: + - keep-alive + Keep-Alive: + - timeout=5 + Server: + - Fly/d1ad1f94 (2025-12-05) + Via: + - 1.1 fly.io, 1.1 fly.io + Fly-Request-Id: + - 01KBQQPRA6BK1NWCWGEEYH0PZK-lax + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{"results":[{"type":"update","urgency":"high","thirdparty_id":"12345"}],"status":"query + matched","processedLogData":null}' + recorded_at: Fri, 05 Dec 2025 17:07:10 GMT +recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/custom_webhook_v3_adapter_process_update.yml b/test/vcr_cassettes/custom_webhook_v3_adapter_process_update.yml new file mode 100644 index 0000000..6ebaced --- /dev/null +++ b/test/vcr_cassettes/custom_webhook_v3_adapter_process_update.yml @@ -0,0 +1,50 @@ +--- +http_interactions: +- request: + method: post + uri: "" + body: + encoding: UTF-8 + string: '{"log":{"remote_ip":"127.0.0.1","url":"https://integration.url/int_123","method":"post","headers":{"User-Agent":"UptimeRobot/1.0","Content-Type":"application/json"},"data":{"monitorID":"12345","alertTypeFriendlyName":"Degraded","monitorFriendlyName":"My + Website","alertDetails":"Connection timeout","monitorURL":"https://example.com","urgency":"high","alertDateTime":1733126400},"params":null,"body":{"monitorID":"12345","alertTypeFriendlyName":"Degraded","monitorFriendlyName":"My + Website","alertDetails":"Connection timeout","monitorURL":"https://example.com","urgency":"high","alertDateTime":1733126400}},"config":{"rules":[{"match":{"log.data.alertTypeFriendlyName":{"$regex":"^degraded$","$options":"i"}},"actions":[{"type":"update","urgency":"{{log.data.urgency}}","thirdparty_id":"{{log.data.monitorID}}"}]}]}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + X-Powered-By: + - Express + Content-Type: + - application/json; charset=utf-8 + Etag: + - W/"223-aAaPHh/aQ2lRQrKECMXb4swg5io" + Date: + - Fri, 05 Dec 2025 17:07:11 GMT + Connection: + - keep-alive + Keep-Alive: + - timeout=5 + Server: + - Fly/d1ad1f94 (2025-12-05) + Via: + - 1.1 fly.io, 1.1 fly.io + Fly-Request-Id: + - 01KBQQPRA6BK1NWCWGEEYH0PZK-lax + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{"results":[{"type":"update","urgency":"high","thirdparty_id":"12345","incident":false}],"status":"query + matched","processedLogData":null}' + recorded_at: Fri, 05 Dec 2025 17:07:10 GMT +recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/email_v3_custom_adapter_action_update.yml b/test/vcr_cassettes/email_v3_custom_adapter_action_update.yml new file mode 100644 index 0000000..8924ff8 --- /dev/null +++ b/test/vcr_cassettes/email_v3_custom_adapter_action_update.yml @@ -0,0 +1,49 @@ +--- +http_interactions: +- request: + method: post + uri: "" + body: + encoding: UTF-8 + string: '{"log":{"subject":"Server is DEGRADED","body":"Full downtime + details here...","from":["alerts@server.com"],"to":["inbox@pagertree.com"]},"config":{"rules":[{"match":{"log.subject":{"$regex":"degraded","$options":"i"}},"actions":[{"type":"update","urgency":"high","thirdparty_id":"email-test-123"}]}]}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + X-Powered-By: + - Express + Content-Type: + - application/json; charset=utf-8 + Etag: + - W/"1b2-DWiqlspc76kS9yLS/fUvzB7y0SQ" + Date: + - Wed, 18 Feb 2026 18:04:07 GMT + Connection: + - keep-alive + Keep-Alive: + - timeout=5 + Server: + - Fly/84caf4a9 (2026-02-18) + Via: + - 1.1 fly.io, 1.1 fly.io + Fly-Request-Id: + - 01KHRYRX6BKXM9XSSKGS3CH1PZ-lax + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{"results":[{"type":"update","urgency":"high","thirdparty_id":"email-test-123"}],"status":"query + matched","processedLogData":null}' + recorded_at: Wed, 18 Feb 2026 18:04:06 GMT +recorded_with: VCR 6.0.0 diff --git a/test/vcr_cassettes/email_v3_custom_adapter_process_update.yml b/test/vcr_cassettes/email_v3_custom_adapter_process_update.yml new file mode 100644 index 0000000..57a9208 --- /dev/null +++ b/test/vcr_cassettes/email_v3_custom_adapter_process_update.yml @@ -0,0 +1,49 @@ +--- +http_interactions: +- request: + method: post + uri: "" + body: + encoding: UTF-8 + string: '{"log":{"subject":"Server is DEGRADED","body":"Full downtime + details here...","from":["alerts@server.com"],"to":["inbox@pagertree.com"]},"config":{"rules":[{"match":{"log.subject":{"$regex":"degraded","$options":"i"}},"actions":[{"type":"update","urgency":"high","thirdparty_id":"email-test-123"}]}]}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + X-Powered-By: + - Express + Content-Type: + - application/json; charset=utf-8 + Etag: + - W/"1b2-DWiqlspc76kS9yLS/fUvzB7y0SQ" + Date: + - Wed, 18 Feb 2026 18:04:07 GMT + Connection: + - keep-alive + Keep-Alive: + - timeout=5 + Server: + - Fly/84caf4a9 (2026-02-18) + Via: + - 1.1 fly.io, 1.1 fly.io + Fly-Request-Id: + - 01KHRYRX6BKXM9XSSKGS3CH1PZ-lax + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: '{"results":[{"type":"update","urgency":"high","thirdparty_id":"email-test-123","incident":false}],"status":"query + matched","processedLogData":null}' + recorded_at: Wed, 18 Feb 2026 18:04:06 GMT +recorded_with: VCR 6.0.0