From 779fadddf4510f79305fcefaedbadff5db2b800c Mon Sep 17 00:00:00 2001 From: Jez-A Date: Mon, 9 Mar 2026 10:54:04 +0000 Subject: [PATCH 1/6] fix: Name bare _ wildcards to satisfy Credo consistency check Co-Authored-By: Claude Sonnet 4.6 --- mix.lock | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mix.lock b/mix.lock index 1442796..079c4b4 100644 --- a/mix.lock +++ b/mix.lock @@ -11,8 +11,10 @@ "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "influxql": {:hex, :influxql, "0.2.1", "71bfd5c0d81bf870f239baf3357bf5226b44fce16e1b9399ba1368203ca71245", [:mix], [], "hexpm", "75faf04960d6830ca0827869eaac1ba092655041c5e96deb2a588bafb601205c"}, "instream": {:hex, :instream, "2.2.1", "8f27352b0490f3d43387d9dfb926e6235570ea8a52b3675347c98efd7863a86d", [:mix], [{:hackney, "~> 1.1", [hex: :hackney, repo: "hexpm", optional: false]}, {:influxql, "~> 0.2.0", [hex: :influxql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "e20c7cc24991fdd228fa93dc080ee7b9683f4c1509b3b718fdd385128d018c2a"}, @@ -25,15 +27,20 @@ "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "mock": {:hex, :mock, "0.3.9", "10e44ad1f5962480c5c9b9fa779c6c63de9bd31997c8e04a853ec990a9d841af", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "9e1b244c4ca2551bb17bb8415eed89e40ee1308e0fbaed0a4fdfe3ec8a4adbd3"}, "nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "opentelemetry_api": {:hex, :opentelemetry_api, "1.5.0", "1a676f3e3340cab81c763e939a42e11a70c22863f645aa06aafefc689b5550cf", [:mix, :rebar3], [], "hexpm", "f53ec8a1337ae4a487d43ac89da4bd3a3c99ddf576655d071deed8b56a2d5dda"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "quickrand": {:hex, :quickrand, "2.0.7", "d2bd76676a446e6a058d678444b7fda1387b813710d1af6d6e29bb92186c8820", [:rebar3], [], "hexpm", "b8acbf89a224bc217c3070ca8bebc6eb236dbe7f9767993b274084ea044d35f0"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "shotgun": {:hex, :shotgun, "1.2.1", "a720063b49a763a97b245cc1ab6ee34e0e50d1ef61858e080db8e3b0dcd31af2", [:rebar3], [{:gun, "2.2.0", [hex: :gun, repo: "hexpm", optional: false]}], "hexpm", "a5ed7a1ff851419a70e292c4e2649c4d2c633141eb9a3432a4896c72b6d3f212"}, "sobelow": {:hex, :sobelow, "0.14.0", "dd82aae8f72503f924fe9dd97ffe4ca694d2f17ec463dcfd365987c9752af6ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7ecf91e298acfd9b24f5d761f19e8f6e6ac585b9387fb6301023f1f2cd5eed5f"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, From 381ca7cef9200e54d939dedf0cbf74f6e5adb603 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 14:52:18 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20Add=20AutoEscalation=20=E2=80=94=20?= =?UTF-8?q?Elixir=20port=20of=20Opsbox::AutoEscalation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Zexbox.AutoEscalation, which handles automatic error-to-on-call escalation via Jira. When an error is rescued, this module: 1. Generates a fingerprint (action::ErrorClass) and searches Jira for an open ticket with that fingerprint (exact-match JQL, excludes resolved statuses: Done / No Further Action / Ready for Support Approval). 2. If found: adds an ADF comment documenting the new occurrence. 3. If not found: creates a Bug ticket with ADF description, sets the fingerprint and ZIGL Team custom fields, then transitions to "To do". Returns {:ok, ticket_map}, {:error, reason}, or {:disabled, nil}. Uses the SP project key in :production, SS otherwise. Usage from a rescue block: rescue e -> Zexbox.AutoEscalation.handle_error( error: e, stacktrace: __STACKTRACE__, action: "checkout", priority: "High", zigl_team: "Purchase Ops" ) Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/auto_escalation.ex | 282 +++++++++++++++++++ test/zexbox/auto_escalation_test.exs | 390 +++++++++++++++++++++++++++ 2 files changed, 672 insertions(+) create mode 100644 lib/zexbox/auto_escalation.ex create mode 100644 test/zexbox/auto_escalation_test.exs diff --git a/lib/zexbox/auto_escalation.ex b/lib/zexbox/auto_escalation.ex new file mode 100644 index 0000000..94e82a4 --- /dev/null +++ b/lib/zexbox/auto_escalation.ex @@ -0,0 +1,282 @@ +defmodule Zexbox.AutoEscalation do + @moduledoc """ + Automatic error-to-on-call escalation via Jira. + + Mirrors `Opsbox::AutoEscalation`. When an error occurs, this module finds an + existing open Jira ticket by fingerprint or creates a new Bug, adds a comment on + recurrence, and always transitions new tickets to "To do" to trigger the medic/IRM + process. + + ## Usage + + Call from a `rescue` block and pass `__STACKTRACE__` so the stack trace is + included in the ticket: + + ```elixir + try do + process_checkout(user, basket) + rescue + e -> + Zexbox.AutoEscalation.handle_error( + error: e, + stacktrace: __STACKTRACE__, + action: "checkout", + priority: "High", + zigl_team: "Purchase Ops", + user_context: %{email: user.email}, + additional_context: %{basket_id: basket.id} + ) + end + ``` + + ## Return values + + - `{:ok, ticket_map}` – ticket found or created; map has `"key"`, `"id"`, `"self"`, `"url"`. + - `{:error, reason}` – ticket creation or transition failed; rescue `Zexbox.AutoEscalation.Error`. + - `{:disabled, nil}` – feature is disabled via config (no Jira calls made). + + ## Configuration + + ```elixir + config :zexbox, + jira_base_url: "https://zigroup.atlassian.net", + jira_email: System.get_env("JIRA_USER_EMAIL_ADDRESS"), + jira_api_token: System.get_env("JIRA_API_TOKEN"), + auto_escalation_enabled: true, + app_env: :production # :production → SP project; anything else → SS project + ``` + + Disable per environment: + + ```elixir + # config/dev.exs + config :zexbox, auto_escalation_enabled: false + ``` + """ + + require Logger + + alias Zexbox.{JiraClient, AutoEscalation.AdfBuilder} + + defmodule Error do + @moduledoc "Raised when Jira ticket creation or transition fails." + defexception [:message] + end + + @project_key_sandbox "SS" + @project_key_support "SP" + # Tickets in these statuses are considered resolved; new occurrences add a comment instead. + @resolved_statuses ["Done", "No Further Action", "Ready for Support Approval"] + @transition_to "To do" + @issuetype "Bug" + @compile_env Mix.env() + + @doc """ + Handle an error by finding or creating a Jira ticket. + + Required options: + - `:error` – the `Exception.t()` that was rescued. + - `:action` – short label (e.g. `"checkout"`); used in the fingerprint and summary. + - `:priority` – Jira priority name (e.g. `"High"`). + - `:zigl_team` – value for the ZIGL Team custom field. + + Optional options: + - `:stacktrace` – pass `__STACKTRACE__` from the rescue block for a full trace. + - `:user_context` – map rendered as a bullet list in the ticket body. + - `:additional_context` – map of extra key/value pairs in the ticket body. + - `:fingerprint` – override deduplication key; defaults to `"action::ErrorClass"`. + - `:custom_description` – string rendered above Error Details (split on `\\n\\n`). + """ + @spec handle_error(keyword()) :: {:ok, map()} | {:error, term()} | {:disabled, nil} + def handle_error(opts) do + if auto_escalation_enabled?() do + do_handle_error(opts) + else + {:disabled, nil} + end + end + + @doc """ + Generates the default deduplication fingerprint for an error. + + ## Examples + + iex> Zexbox.AutoEscalation.generate_fingerprint(error_class: "StandardError", action: "checkout") + "checkout::StandardError" + """ + @spec generate_fingerprint(keyword()) :: String.t() + def generate_fingerprint(opts) do + error_class = Keyword.fetch!(opts, :error_class) + action = Keyword.fetch!(opts, :action) + "#{action}::#{error_class}" + end + + # --- Private --- + + defp do_handle_error(opts) do + error = Keyword.fetch!(opts, :error) + + unless is_exception(error) do + raise ArgumentError, "Expected an Exception.t() for :error, got: #{inspect(error)}" + end + + action = Keyword.fetch!(opts, :action) + priority = Keyword.fetch!(opts, :priority) + zigl_team = Keyword.fetch!(opts, :zigl_team) + user_context = Keyword.get(opts, :user_context, %{}) + additional_context = Keyword.get(opts, :additional_context, %{}) + stacktrace = Keyword.get(opts, :stacktrace) + custom_description = Keyword.get(opts, :custom_description) + + error_class = inspect(error.__struct__) + + fingerprint = + Keyword.get(opts, :fingerprint) || + generate_fingerprint(error_class: error_class, action: action) + + case find_existing_ticket(fingerprint) do + nil -> + create_jira_ticket( + error: error, + action: action, + priority: priority, + zigl_team: zigl_team, + fingerprint: fingerprint, + user_context: user_context, + additional_context: additional_context, + custom_description: custom_description, + stacktrace: stacktrace + ) + + existing_ticket -> + add_comment_to_existing_ticket( + ticket: existing_ticket, + error: error, + action: action, + user_context: user_context, + additional_context: additional_context, + custom_description: custom_description, + stacktrace: stacktrace + ) + + {:ok, existing_ticket} + end + end + + defp find_existing_ticket(fingerprint) do + project_key = resolve_project_key() + escaped = String.replace(fingerprint, "\"", "\\\"") + field_name = JiraClient.bug_fingerprint_field().name + status_list = Enum.map_join(@resolved_statuses, ", ", fn s -> "\"#{s}\"" end) + jql = "\"#{field_name}\" = \"#{escaped}\" AND status NOT IN (#{status_list})" + + case JiraClient.search_latest_issues(jql: jql, project_key: project_key) do + {:ok, []} -> + nil + + {:ok, [first | _]} -> + first + + {:error, e} -> + Logger.error( + "[Zexbox.AutoEscalation] Failed to find existing Jira ticket with fingerprint #{fingerprint}: #{inspect(e)}" + ) + + nil + end + end + + defp create_jira_ticket(opts) do + error = Keyword.fetch!(opts, :error) + action = Keyword.fetch!(opts, :action) + priority = Keyword.fetch!(opts, :priority) + zigl_team = Keyword.fetch!(opts, :zigl_team) + fingerprint = Keyword.fetch!(opts, :fingerprint) + user_context = Keyword.fetch!(opts, :user_context) + additional_context = Keyword.fetch!(opts, :additional_context) + custom_description = Keyword.get(opts, :custom_description) + stacktrace = Keyword.get(opts, :stacktrace) + + project_key = resolve_project_key() + error_class = inspect(error.__struct__) + + description = + AdfBuilder.build_description( + error, + user_context, + additional_context, + custom_description: custom_description, + stacktrace: stacktrace + ) + + custom_fields = %{ + JiraClient.bug_fingerprint_field().id => fingerprint, + JiraClient.zigl_team_field().id => %{"value" => zigl_team} + } + + with {:ok, result} <- + JiraClient.create_issue( + project_key: project_key, + summary: "#{action}: #{error_class}", + description: description, + issuetype: @issuetype, + priority: priority, + custom_fields: custom_fields + ), + {:ok, _} <- + JiraClient.transition_issue(issue_key: result["key"], status_name: @transition_to) do + {:ok, result} + else + {:error, e} -> + Logger.error( + "[Zexbox.AutoEscalation] Failed to create Jira ticket with fingerprint #{fingerprint} and action #{action}: #{inspect(e)}" + ) + + {:error, "Failed to create Jira ticket: #{inspect(e)}"} + end + end + + defp add_comment_to_existing_ticket(opts) do + ticket = Keyword.fetch!(opts, :ticket) + error = Keyword.fetch!(opts, :error) + action = Keyword.fetch!(opts, :action) + user_context = Keyword.fetch!(opts, :user_context) + additional_context = Keyword.fetch!(opts, :additional_context) + custom_description = Keyword.get(opts, :custom_description) + stacktrace = Keyword.get(opts, :stacktrace) + + issue_key = ticket["key"] + + comment = + AdfBuilder.build_comment( + error, + action, + user_context, + additional_context, + custom_description: custom_description, + stacktrace: stacktrace + ) + + case JiraClient.add_comment(issue_key: issue_key, comment: comment) do + {:ok, _} -> + :ok + + {:error, e} -> + Logger.error( + "[Zexbox.AutoEscalation] Failed to add comment to Jira ticket #{issue_key}: #{inspect(e)}" + ) + + :ok + end + end + + defp resolve_project_key do + if app_env() == :production, do: @project_key_support, else: @project_key_sandbox + end + + defp auto_escalation_enabled? do + Application.get_env(:zexbox, :auto_escalation_enabled, true) == true + end + + defp app_env, do: Application.get_env(:zexbox, :app_env, @compile_env) +end diff --git a/test/zexbox/auto_escalation_test.exs b/test/zexbox/auto_escalation_test.exs new file mode 100644 index 0000000..b15b150 --- /dev/null +++ b/test/zexbox/auto_escalation_test.exs @@ -0,0 +1,390 @@ +defmodule Zexbox.AutoEscalationTest do + use ExUnit.Case + import ExUnit.CaptureLog + import Mock + alias Zexbox.AutoEscalation + + @existing_ticket %{ + "key" => "SS-42", + "id" => "10042", + "self" => "https://zigroup.atlassian.net/rest/api/3/issue/10042", + "url" => "https://zigroup.atlassian.net/browse/SS-42" + } + + @created_ticket %{ + "key" => "SS-1", + "id" => "10001", + "self" => "https://zigroup.atlassian.net/rest/api/3/issue/10001", + "url" => "https://zigroup.atlassian.net/browse/SS-1" + } + + defp jira_mocks(overrides \\ []) do + Keyword.merge( + [ + search_latest_issues: fn _opts -> {:ok, []} end, + create_issue: fn _opts -> {:ok, @created_ticket} end, + transition_issue: fn _opts -> {:ok, %{success: true, status: "To do"}} end, + add_comment: fn _opts -> {:ok, %{"id" => "comment-1"}} end, + bug_fingerprint_field: fn -> + %{id: "customfield_13442", name: "Bug Fingerprint[Short text]"} + end, + zigl_team_field: fn -> %{id: "customfield_10101", name: "ZIGL Team[Dropdown]"} end + ], + overrides + ) + end + + defp otel_mocks(overrides \\ []) do + Keyword.merge( + [ + datadog_session_url: fn -> nil end, + generate_trace_url: fn -> nil end, + kibana_log_url: fn -> nil end + ], + overrides + ) + end + + defp all_mocks(jira_overrides \\ [], otel_overrides \\ []) do + [ + {Zexbox.JiraClient, [], jira_mocks(jira_overrides)}, + {Zexbox.OpenTelemetry, [], otel_mocks(otel_overrides)} + ] + end + + defp error, do: %RuntimeError{message: "Something broke"} + + # Capturing mock helper — returns the opts that were passed to create_issue. + defp capture_create_issue do + me = self() + [create_issue: fn opts -> send(me, {:create_opts, opts}); {:ok, @created_ticket} end] + end + + defp capture_add_comment do + me = self() + [add_comment: fn opts -> send(me, {:comment_opts, opts}); {:ok, %{}} end] + end + + defp capture_search do + me = self() + [search_latest_issues: fn opts -> send(me, {:search_opts, opts}); {:ok, []} end] + end + + setup do + Application.put_env(:zexbox, :auto_escalation_enabled, true) + Application.put_env(:zexbox, :app_env, :sandbox) + + on_exit(fn -> + Application.delete_env(:zexbox, :auto_escalation_enabled) + Application.delete_env(:zexbox, :app_env) + end) + + :ok + end + + describe "generate_fingerprint/1" do + test "returns action::ErrorClass format" do + assert "checkout::StandardError" = + AutoEscalation.generate_fingerprint( + error_class: "StandardError", + action: "checkout" + ) + end + end + + describe "handle_error/1 — when disabled" do + test "returns {:disabled, nil} without calling Jira" do + Application.put_env(:zexbox, :auto_escalation_enabled, false) + + with_mocks(all_mocks()) do + result = + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Ops" + ) + + assert result == {:disabled, nil} + refute called(Zexbox.JiraClient.search_latest_issues(:_)) + refute called(Zexbox.JiraClient.create_issue(:_)) + end + end + end + + describe "handle_error/1 — new ticket path" do + test "returns {:ok, ticket} and calls create_issue" do + with_mocks(all_mocks()) do + assert {:ok, ticket} = + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Purchase Ops" + ) + + assert ticket["key"] == "SS-1" + assert called(Zexbox.JiraClient.create_issue(:_)) + end + end + + test "creates issue with correct project key, summary, type, and priority" do + with_mocks(all_mocks(capture_create_issue())) do + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Purchase Ops" + ) + + assert_received {:create_opts, opts} + assert Keyword.get(opts, :project_key) == "SS" + assert Keyword.get(opts, :summary) == "checkout: RuntimeError" + assert Keyword.get(opts, :issuetype) == "Bug" + assert Keyword.get(opts, :priority) == "High" + end + end + + test "uses SP project key in production" do + Application.put_env(:zexbox, :app_env, :production) + + with_mocks(all_mocks(capture_create_issue())) do + AutoEscalation.handle_error( + error: error(), + action: "pay", + priority: "High", + zigl_team: "Payments" + ) + + assert_received {:create_opts, opts} + assert Keyword.get(opts, :project_key) == "SP" + end + end + + test "always transitions the new ticket to 'To do'" do + with_mocks(all_mocks()) do + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Ops" + ) + + assert called(Zexbox.JiraClient.transition_issue(:_)) + end + end + + test "transitions to 'To do' using the ticket key returned by create_issue" do + me = self() + + jira_overrides = [ + transition_issue: fn opts -> send(me, {:transition_opts, opts}); {:ok, %{}} end + ] + + with_mocks(all_mocks(jira_overrides)) do + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Ops" + ) + + assert_received {:transition_opts, opts} + assert Keyword.get(opts, :issue_key) == "SS-1" + assert Keyword.get(opts, :status_name) == "To do" + end + end + + test "sets bug fingerprint custom field to the generated fingerprint" do + with_mocks(all_mocks(capture_create_issue())) do + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Purchase Ops" + ) + + assert_received {:create_opts, opts} + custom_fields = Keyword.get(opts, :custom_fields) + assert custom_fields["customfield_13442"] == "checkout::RuntimeError" + end + end + + test "sets zigl team custom field" do + with_mocks(all_mocks(capture_create_issue())) do + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Purchase Ops" + ) + + assert_received {:create_opts, opts} + custom_fields = Keyword.get(opts, :custom_fields) + assert custom_fields["customfield_10101"] == %{"value" => "Purchase Ops"} + end + end + + test "uses custom fingerprint override when provided" do + with_mocks(all_mocks(capture_create_issue())) do + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Ops", + fingerprint: "custom::fingerprint" + ) + + assert_received {:create_opts, opts} + custom_fields = Keyword.get(opts, :custom_fields) + assert custom_fields["customfield_13442"] == "custom::fingerprint" + end + end + + test "description is a valid ADF doc map" do + with_mocks(all_mocks(capture_create_issue())) do + AutoEscalation.handle_error( + error: error(), + action: "pay", + priority: "Medium", + zigl_team: "Payments", + user_context: %{email: "u@example.com"}, + additional_context: %{basket_id: 123} + ) + + assert_received {:create_opts, opts} + description = Keyword.get(opts, :description) + assert description.version == 1 + assert description.type == "doc" + assert is_list(description.content) + end + end + + test "JQL search includes all resolved statuses to exclude closed tickets" do + with_mocks(all_mocks(capture_search())) do + AutoEscalation.handle_error( + error: error(), + action: "pay", + priority: "High", + zigl_team: "Payments" + ) + + assert_received {:search_opts, opts} + jql = Keyword.get(opts, :jql, "") + assert jql =~ "Done" + assert jql =~ "No Further Action" + assert jql =~ "Ready for Support Approval" + end + end + + test "returns {:error, reason} and logs when create_issue fails" do + jira_overrides = [create_issue: fn _opts -> {:error, "JIRA down"} end] + + log = + capture_log(fn -> + with_mocks(all_mocks(jira_overrides)) do + assert {:error, reason} = + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Ops" + ) + + assert reason =~ "Failed to create Jira ticket" + end + end) + + assert log =~ "Failed to create Jira ticket" + end + end + + describe "handle_error/1 — existing ticket path" do + test "returns the existing ticket and adds a comment" do + jira_overrides = [search_latest_issues: fn _opts -> {:ok, [@existing_ticket]} end] + + with_mocks(all_mocks(jira_overrides)) do + assert {:ok, ticket} = + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Ops" + ) + + assert ticket["key"] == "SS-42" + refute called(Zexbox.JiraClient.create_issue(:_)) + assert called(Zexbox.JiraClient.add_comment(:_)) + end + end + + test "comment targets the existing ticket key" do + jira_overrides = + [search_latest_issues: fn _opts -> {:ok, [@existing_ticket]} end] ++ + capture_add_comment() + + with_mocks(all_mocks(jira_overrides)) do + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Ops" + ) + end + + assert_received {:comment_opts, opts} + assert Keyword.get(opts, :issue_key) == "SS-42" + comment = Keyword.get(opts, :comment) + assert comment.version == 1 + assert comment.type == "doc" + end + + test "still returns {:ok, ticket} when add_comment fails" do + jira_overrides = [ + search_latest_issues: fn _opts -> {:ok, [@existing_ticket]} end, + add_comment: fn _opts -> {:error, "Comment failed"} end + ] + + log = + capture_log(fn -> + with_mocks(all_mocks(jira_overrides)) do + assert {:ok, ticket} = + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Ops" + ) + + assert ticket["key"] == "SS-42" + end + end) + + assert log =~ "Failed to add comment" + end + end + + describe "handle_error/1 — search failure" do + test "logs and falls through to create a new ticket when search fails" do + jira_overrides = [search_latest_issues: fn _opts -> {:error, "Search failed"} end] + + log = + capture_log(fn -> + with_mocks(all_mocks(jira_overrides)) do + assert {:ok, ticket} = + AutoEscalation.handle_error( + error: error(), + action: "checkout", + priority: "High", + zigl_team: "Ops" + ) + + assert ticket["key"] == "SS-1" + assert called(Zexbox.JiraClient.create_issue(:_)) + end + end) + + assert log =~ "Failed to find existing Jira ticket" + end + end +end From c2c1db5f6a5e86fec4d7e0de2c346d429b35c4cf Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 14:58:21 +0000 Subject: [PATCH 3/6] style: Run mix format Co-Authored-By: Claude Sonnet 4.6 --- test/zexbox/auto_escalation_test.exs | 29 ++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/test/zexbox/auto_escalation_test.exs b/test/zexbox/auto_escalation_test.exs index b15b150..7c84fda 100644 --- a/test/zexbox/auto_escalation_test.exs +++ b/test/zexbox/auto_escalation_test.exs @@ -57,17 +57,35 @@ defmodule Zexbox.AutoEscalationTest do # Capturing mock helper — returns the opts that were passed to create_issue. defp capture_create_issue do me = self() - [create_issue: fn opts -> send(me, {:create_opts, opts}); {:ok, @created_ticket} end] + + [ + create_issue: fn opts -> + send(me, {:create_opts, opts}) + {:ok, @created_ticket} + end + ] end defp capture_add_comment do me = self() - [add_comment: fn opts -> send(me, {:comment_opts, opts}); {:ok, %{}} end] + + [ + add_comment: fn opts -> + send(me, {:comment_opts, opts}) + {:ok, %{}} + end + ] end defp capture_search do me = self() - [search_latest_issues: fn opts -> send(me, {:search_opts, opts}); {:ok, []} end] + + [ + search_latest_issues: fn opts -> + send(me, {:search_opts, opts}) + {:ok, []} + end + ] end setup do @@ -178,7 +196,10 @@ defmodule Zexbox.AutoEscalationTest do me = self() jira_overrides = [ - transition_issue: fn opts -> send(me, {:transition_opts, opts}); {:ok, %{}} end + transition_issue: fn opts -> + send(me, {:transition_opts, opts}) + {:ok, %{}} + end ] with_mocks(all_mocks(jira_overrides)) do From 4b8cad7ac69e0f204caeaee720558f325d466c74 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 15:43:22 +0000 Subject: [PATCH 4/6] refactor: Convert AutoEscalation public API to explicit positional arguments Mirrors the JiraClient refactor: handle_error/5, generate_fingerprint/2, and all private helpers now use explicit positional args instead of keyword lists. JiraClient call sites updated to match the new positional API. Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/auto_escalation.ex | 135 +++++++-------- test/zexbox/auto_escalation_test.exs | 243 ++++++++++----------------- 2 files changed, 152 insertions(+), 226 deletions(-) diff --git a/lib/zexbox/auto_escalation.ex b/lib/zexbox/auto_escalation.ex index 94e82a4..07cfe57 100644 --- a/lib/zexbox/auto_escalation.ex +++ b/lib/zexbox/auto_escalation.ex @@ -18,11 +18,11 @@ defmodule Zexbox.AutoEscalation do rescue e -> Zexbox.AutoEscalation.handle_error( - error: e, + e, + "checkout", + "High", + "Purchase Ops", stacktrace: __STACKTRACE__, - action: "checkout", - priority: "High", - zigl_team: "Purchase Ops", user_context: %{email: user.email}, additional_context: %{basket_id: basket.id} ) @@ -74,11 +74,11 @@ defmodule Zexbox.AutoEscalation do @doc """ Handle an error by finding or creating a Jira ticket. - Required options: - - `:error` – the `Exception.t()` that was rescued. - - `:action` – short label (e.g. `"checkout"`); used in the fingerprint and summary. - - `:priority` – Jira priority name (e.g. `"High"`). - - `:zigl_team` – value for the ZIGL Team custom field. + Required arguments: + - `error` – the `Exception.t()` that was rescued. + - `action` – short label (e.g. `"checkout"`); used in the fingerprint and summary. + - `priority` – Jira priority name (e.g. `"High"`). + - `zigl_team` – value for the ZIGL Team custom field. Optional options: - `:stacktrace` – pass `__STACKTRACE__` from the rescue block for a full trace. @@ -87,10 +87,11 @@ defmodule Zexbox.AutoEscalation do - `:fingerprint` – override deduplication key; defaults to `"action::ErrorClass"`. - `:custom_description` – string rendered above Error Details (split on `\\n\\n`). """ - @spec handle_error(keyword()) :: {:ok, map()} | {:error, term()} | {:disabled, nil} - def handle_error(opts) do + @spec handle_error(Exception.t(), String.t(), String.t(), String.t(), keyword()) :: + {:ok, map()} | {:error, term()} | {:disabled, nil} + def handle_error(error, action, priority, zigl_team, opts \\ []) do if auto_escalation_enabled?() do - do_handle_error(opts) + do_handle_error(error, action, priority, zigl_team, opts) else {:disabled, nil} end @@ -101,28 +102,19 @@ defmodule Zexbox.AutoEscalation do ## Examples - iex> Zexbox.AutoEscalation.generate_fingerprint(error_class: "StandardError", action: "checkout") + iex> Zexbox.AutoEscalation.generate_fingerprint("StandardError", "checkout") "checkout::StandardError" """ - @spec generate_fingerprint(keyword()) :: String.t() - def generate_fingerprint(opts) do - error_class = Keyword.fetch!(opts, :error_class) - action = Keyword.fetch!(opts, :action) - "#{action}::#{error_class}" - end + @spec generate_fingerprint(String.t(), String.t()) :: String.t() + def generate_fingerprint(error_class, action), do: "#{action}::#{error_class}" # --- Private --- - defp do_handle_error(opts) do - error = Keyword.fetch!(opts, :error) - + defp do_handle_error(error, action, priority, zigl_team, opts) do unless is_exception(error) do raise ArgumentError, "Expected an Exception.t() for :error, got: #{inspect(error)}" end - action = Keyword.fetch!(opts, :action) - priority = Keyword.fetch!(opts, :priority) - zigl_team = Keyword.fetch!(opts, :zigl_team) user_context = Keyword.get(opts, :user_context, %{}) additional_context = Keyword.get(opts, :additional_context, %{}) stacktrace = Keyword.get(opts, :stacktrace) @@ -132,31 +124,31 @@ defmodule Zexbox.AutoEscalation do fingerprint = Keyword.get(opts, :fingerprint) || - generate_fingerprint(error_class: error_class, action: action) + generate_fingerprint(error_class, action) case find_existing_ticket(fingerprint) do nil -> create_jira_ticket( - error: error, - action: action, - priority: priority, - zigl_team: zigl_team, - fingerprint: fingerprint, - user_context: user_context, - additional_context: additional_context, - custom_description: custom_description, - stacktrace: stacktrace + error, + action, + priority, + zigl_team, + fingerprint, + user_context, + additional_context, + custom_description, + stacktrace ) existing_ticket -> add_comment_to_existing_ticket( - ticket: existing_ticket, - error: error, - action: action, - user_context: user_context, - additional_context: additional_context, - custom_description: custom_description, - stacktrace: stacktrace + existing_ticket, + error, + action, + user_context, + additional_context, + custom_description, + stacktrace ) {:ok, existing_ticket} @@ -170,7 +162,7 @@ defmodule Zexbox.AutoEscalation do status_list = Enum.map_join(@resolved_statuses, ", ", fn s -> "\"#{s}\"" end) jql = "\"#{field_name}\" = \"#{escaped}\" AND status NOT IN (#{status_list})" - case JiraClient.search_latest_issues(jql: jql, project_key: project_key) do + case JiraClient.search_latest_issues(jql, project_key) do {:ok, []} -> nil @@ -186,17 +178,17 @@ defmodule Zexbox.AutoEscalation do end end - defp create_jira_ticket(opts) do - error = Keyword.fetch!(opts, :error) - action = Keyword.fetch!(opts, :action) - priority = Keyword.fetch!(opts, :priority) - zigl_team = Keyword.fetch!(opts, :zigl_team) - fingerprint = Keyword.fetch!(opts, :fingerprint) - user_context = Keyword.fetch!(opts, :user_context) - additional_context = Keyword.fetch!(opts, :additional_context) - custom_description = Keyword.get(opts, :custom_description) - stacktrace = Keyword.get(opts, :stacktrace) - + defp create_jira_ticket( + error, + action, + priority, + zigl_team, + fingerprint, + user_context, + additional_context, + custom_description, + stacktrace + ) do project_key = resolve_project_key() error_class = inspect(error.__struct__) @@ -216,15 +208,14 @@ defmodule Zexbox.AutoEscalation do with {:ok, result} <- JiraClient.create_issue( - project_key: project_key, - summary: "#{action}: #{error_class}", - description: description, - issuetype: @issuetype, - priority: priority, - custom_fields: custom_fields + project_key, + "#{action}: #{error_class}", + description, + @issuetype, + priority, + custom_fields ), - {:ok, _} <- - JiraClient.transition_issue(issue_key: result["key"], status_name: @transition_to) do + {:ok, _} <- JiraClient.transition_issue(result["key"], @transition_to) do {:ok, result} else {:error, e} -> @@ -236,15 +227,15 @@ defmodule Zexbox.AutoEscalation do end end - defp add_comment_to_existing_ticket(opts) do - ticket = Keyword.fetch!(opts, :ticket) - error = Keyword.fetch!(opts, :error) - action = Keyword.fetch!(opts, :action) - user_context = Keyword.fetch!(opts, :user_context) - additional_context = Keyword.fetch!(opts, :additional_context) - custom_description = Keyword.get(opts, :custom_description) - stacktrace = Keyword.get(opts, :stacktrace) - + defp add_comment_to_existing_ticket( + ticket, + error, + action, + user_context, + additional_context, + custom_description, + stacktrace + ) do issue_key = ticket["key"] comment = @@ -257,7 +248,7 @@ defmodule Zexbox.AutoEscalation do stacktrace: stacktrace ) - case JiraClient.add_comment(issue_key: issue_key, comment: comment) do + case JiraClient.add_comment(issue_key, comment) do {:ok, _} -> :ok diff --git a/test/zexbox/auto_escalation_test.exs b/test/zexbox/auto_escalation_test.exs index 7c84fda..e175214 100644 --- a/test/zexbox/auto_escalation_test.exs +++ b/test/zexbox/auto_escalation_test.exs @@ -21,10 +21,14 @@ defmodule Zexbox.AutoEscalationTest do defp jira_mocks(overrides \\ []) do Keyword.merge( [ - search_latest_issues: fn _opts -> {:ok, []} end, - create_issue: fn _opts -> {:ok, @created_ticket} end, - transition_issue: fn _opts -> {:ok, %{success: true, status: "To do"}} end, - add_comment: fn _opts -> {:ok, %{"id" => "comment-1"}} end, + search_latest_issues: fn _jql, _project_key -> {:ok, []} end, + create_issue: fn _project_key, _summary, _desc, _type, _priority, _fields -> + {:ok, @created_ticket} + end, + transition_issue: fn _issue_key, _status_name -> + {:ok, %{success: true, status: "To do"}} + end, + add_comment: fn _issue_key, _comment -> {:ok, %{"id" => "comment-1"}} end, bug_fingerprint_field: fn -> %{id: "customfield_13442", name: "Bug Fingerprint[Short text]"} end, @@ -54,13 +58,25 @@ defmodule Zexbox.AutoEscalationTest do defp error, do: %RuntimeError{message: "Something broke"} - # Capturing mock helper — returns the opts that were passed to create_issue. + # Capturing mock helper — returns the args that were passed to create_issue. defp capture_create_issue do me = self() [ - create_issue: fn opts -> - send(me, {:create_opts, opts}) + create_issue: fn project_key, summary, description, issuetype, priority, custom_fields -> + send( + me, + {:create_opts, + %{ + project_key: project_key, + summary: summary, + description: description, + issuetype: issuetype, + priority: priority, + custom_fields: custom_fields + }} + ) + {:ok, @created_ticket} end ] @@ -70,8 +86,8 @@ defmodule Zexbox.AutoEscalationTest do me = self() [ - add_comment: fn opts -> - send(me, {:comment_opts, opts}) + add_comment: fn issue_key, comment -> + send(me, {:comment_opts, {issue_key, comment}}) {:ok, %{}} end ] @@ -81,8 +97,8 @@ defmodule Zexbox.AutoEscalationTest do me = self() [ - search_latest_issues: fn opts -> - send(me, {:search_opts, opts}) + search_latest_issues: fn jql, _project_key -> + send(me, {:search_jql, jql}) {:ok, []} end ] @@ -100,66 +116,47 @@ defmodule Zexbox.AutoEscalationTest do :ok end - describe "generate_fingerprint/1" do + describe "generate_fingerprint/2" do test "returns action::ErrorClass format" do assert "checkout::StandardError" = - AutoEscalation.generate_fingerprint( - error_class: "StandardError", - action: "checkout" - ) + AutoEscalation.generate_fingerprint("StandardError", "checkout") end end - describe "handle_error/1 — when disabled" do + describe "handle_error/5 — when disabled" do test "returns {:disabled, nil} without calling Jira" do Application.put_env(:zexbox, :auto_escalation_enabled, false) with_mocks(all_mocks()) do - result = - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Ops" - ) + result = AutoEscalation.handle_error(error(), "checkout", "High", "Ops") assert result == {:disabled, nil} - refute called(Zexbox.JiraClient.search_latest_issues(:_)) - refute called(Zexbox.JiraClient.create_issue(:_)) + refute called(Zexbox.JiraClient.search_latest_issues(:_, :_)) + refute called(Zexbox.JiraClient.create_issue(:_, :_, :_, :_, :_, :_)) end end end - describe "handle_error/1 — new ticket path" do + describe "handle_error/5 — new ticket path" do test "returns {:ok, ticket} and calls create_issue" do with_mocks(all_mocks()) do assert {:ok, ticket} = - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Purchase Ops" - ) + AutoEscalation.handle_error(error(), "checkout", "High", "Purchase Ops") assert ticket["key"] == "SS-1" - assert called(Zexbox.JiraClient.create_issue(:_)) + assert called(Zexbox.JiraClient.create_issue(:_, :_, :_, :_, :_, :_)) end end test "creates issue with correct project key, summary, type, and priority" do with_mocks(all_mocks(capture_create_issue())) do - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Purchase Ops" - ) + AutoEscalation.handle_error(error(), "checkout", "High", "Purchase Ops") assert_received {:create_opts, opts} - assert Keyword.get(opts, :project_key) == "SS" - assert Keyword.get(opts, :summary) == "checkout: RuntimeError" - assert Keyword.get(opts, :issuetype) == "Bug" - assert Keyword.get(opts, :priority) == "High" + assert opts.project_key == "SS" + assert opts.summary == "checkout: RuntimeError" + assert opts.issuetype == "Bug" + assert opts.priority == "High" end end @@ -167,28 +164,18 @@ defmodule Zexbox.AutoEscalationTest do Application.put_env(:zexbox, :app_env, :production) with_mocks(all_mocks(capture_create_issue())) do - AutoEscalation.handle_error( - error: error(), - action: "pay", - priority: "High", - zigl_team: "Payments" - ) + AutoEscalation.handle_error(error(), "pay", "High", "Payments") assert_received {:create_opts, opts} - assert Keyword.get(opts, :project_key) == "SP" + assert opts.project_key == "SP" end end test "always transitions the new ticket to 'To do'" do with_mocks(all_mocks()) do - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Ops" - ) + AutoEscalation.handle_error(error(), "checkout", "High", "Ops") - assert called(Zexbox.JiraClient.transition_issue(:_)) + assert called(Zexbox.JiraClient.transition_issue(:_, :_)) end end @@ -196,102 +183,69 @@ defmodule Zexbox.AutoEscalationTest do me = self() jira_overrides = [ - transition_issue: fn opts -> - send(me, {:transition_opts, opts}) + transition_issue: fn issue_key, status_name -> + send(me, {:transition_opts, {issue_key, status_name}}) {:ok, %{}} end ] with_mocks(all_mocks(jira_overrides)) do - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Ops" - ) + AutoEscalation.handle_error(error(), "checkout", "High", "Ops") - assert_received {:transition_opts, opts} - assert Keyword.get(opts, :issue_key) == "SS-1" - assert Keyword.get(opts, :status_name) == "To do" + assert_received {:transition_opts, {issue_key, status_name}} + assert issue_key == "SS-1" + assert status_name == "To do" end end test "sets bug fingerprint custom field to the generated fingerprint" do with_mocks(all_mocks(capture_create_issue())) do - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Purchase Ops" - ) + AutoEscalation.handle_error(error(), "checkout", "High", "Purchase Ops") assert_received {:create_opts, opts} - custom_fields = Keyword.get(opts, :custom_fields) - assert custom_fields["customfield_13442"] == "checkout::RuntimeError" + assert opts.custom_fields["customfield_13442"] == "checkout::RuntimeError" end end test "sets zigl team custom field" do with_mocks(all_mocks(capture_create_issue())) do - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Purchase Ops" - ) + AutoEscalation.handle_error(error(), "checkout", "High", "Purchase Ops") assert_received {:create_opts, opts} - custom_fields = Keyword.get(opts, :custom_fields) - assert custom_fields["customfield_10101"] == %{"value" => "Purchase Ops"} + assert opts.custom_fields["customfield_10101"] == %{"value" => "Purchase Ops"} end end test "uses custom fingerprint override when provided" do with_mocks(all_mocks(capture_create_issue())) do - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Ops", + AutoEscalation.handle_error(error(), "checkout", "High", "Ops", fingerprint: "custom::fingerprint" ) assert_received {:create_opts, opts} - custom_fields = Keyword.get(opts, :custom_fields) - assert custom_fields["customfield_13442"] == "custom::fingerprint" + assert opts.custom_fields["customfield_13442"] == "custom::fingerprint" end end test "description is a valid ADF doc map" do with_mocks(all_mocks(capture_create_issue())) do - AutoEscalation.handle_error( - error: error(), - action: "pay", - priority: "Medium", - zigl_team: "Payments", + AutoEscalation.handle_error(error(), "pay", "Medium", "Payments", user_context: %{email: "u@example.com"}, additional_context: %{basket_id: 123} ) assert_received {:create_opts, opts} - description = Keyword.get(opts, :description) - assert description.version == 1 - assert description.type == "doc" - assert is_list(description.content) + assert opts.description.version == 1 + assert opts.description.type == "doc" + assert is_list(opts.description.content) end end test "JQL search includes all resolved statuses to exclude closed tickets" do with_mocks(all_mocks(capture_search())) do - AutoEscalation.handle_error( - error: error(), - action: "pay", - priority: "High", - zigl_team: "Payments" - ) + AutoEscalation.handle_error(error(), "pay", "High", "Payments") - assert_received {:search_opts, opts} - jql = Keyword.get(opts, :jql, "") + assert_received {:search_jql, jql} assert jql =~ "Done" assert jql =~ "No Further Action" assert jql =~ "Ready for Support Approval" @@ -299,18 +253,17 @@ defmodule Zexbox.AutoEscalationTest do end test "returns {:error, reason} and logs when create_issue fails" do - jira_overrides = [create_issue: fn _opts -> {:error, "JIRA down"} end] + jira_overrides = [ + create_issue: fn _project_key, _summary, _desc, _type, _priority, _fields -> + {:error, "JIRA down"} + end + ] log = capture_log(fn -> with_mocks(all_mocks(jira_overrides)) do assert {:error, reason} = - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Ops" - ) + AutoEscalation.handle_error(error(), "checkout", "High", "Ops") assert reason =~ "Failed to create Jira ticket" end @@ -320,62 +273,47 @@ defmodule Zexbox.AutoEscalationTest do end end - describe "handle_error/1 — existing ticket path" do + describe "handle_error/5 — existing ticket path" do test "returns the existing ticket and adds a comment" do - jira_overrides = [search_latest_issues: fn _opts -> {:ok, [@existing_ticket]} end] + jira_overrides = [ + search_latest_issues: fn _jql, _project_key -> {:ok, [@existing_ticket]} end + ] with_mocks(all_mocks(jira_overrides)) do - assert {:ok, ticket} = - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Ops" - ) + assert {:ok, ticket} = AutoEscalation.handle_error(error(), "checkout", "High", "Ops") assert ticket["key"] == "SS-42" - refute called(Zexbox.JiraClient.create_issue(:_)) - assert called(Zexbox.JiraClient.add_comment(:_)) + refute called(Zexbox.JiraClient.create_issue(:_, :_, :_, :_, :_, :_)) + assert called(Zexbox.JiraClient.add_comment(:_, :_)) end end test "comment targets the existing ticket key" do jira_overrides = - [search_latest_issues: fn _opts -> {:ok, [@existing_ticket]} end] ++ + [search_latest_issues: fn _jql, _project_key -> {:ok, [@existing_ticket]} end] ++ capture_add_comment() with_mocks(all_mocks(jira_overrides)) do - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Ops" - ) + AutoEscalation.handle_error(error(), "checkout", "High", "Ops") end - assert_received {:comment_opts, opts} - assert Keyword.get(opts, :issue_key) == "SS-42" - comment = Keyword.get(opts, :comment) + assert_received {:comment_opts, {issue_key, comment}} + assert issue_key == "SS-42" assert comment.version == 1 assert comment.type == "doc" end test "still returns {:ok, ticket} when add_comment fails" do jira_overrides = [ - search_latest_issues: fn _opts -> {:ok, [@existing_ticket]} end, - add_comment: fn _opts -> {:error, "Comment failed"} end + search_latest_issues: fn _jql, _project_key -> {:ok, [@existing_ticket]} end, + add_comment: fn _issue_key, _comment -> {:error, "Comment failed"} end ] log = capture_log(fn -> with_mocks(all_mocks(jira_overrides)) do assert {:ok, ticket} = - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Ops" - ) + AutoEscalation.handle_error(error(), "checkout", "High", "Ops") assert ticket["key"] == "SS-42" end @@ -385,23 +323,20 @@ defmodule Zexbox.AutoEscalationTest do end end - describe "handle_error/1 — search failure" do + describe "handle_error/5 — search failure" do test "logs and falls through to create a new ticket when search fails" do - jira_overrides = [search_latest_issues: fn _opts -> {:error, "Search failed"} end] + jira_overrides = [ + search_latest_issues: fn _jql, _project_key -> {:error, "Search failed"} end + ] log = capture_log(fn -> with_mocks(all_mocks(jira_overrides)) do assert {:ok, ticket} = - AutoEscalation.handle_error( - error: error(), - action: "checkout", - priority: "High", - zigl_team: "Ops" - ) + AutoEscalation.handle_error(error(), "checkout", "High", "Ops") assert ticket["key"] == "SS-1" - assert called(Zexbox.JiraClient.create_issue(:_)) + assert called(Zexbox.JiraClient.create_issue(:_, :_, :_, :_, :_, :_)) end end) From 91a710ac181b4ad0f779313c9124a4075e7a75a2 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 17:07:58 +0000 Subject: [PATCH 5/6] feat: Remove datadog_session_url from OpenTelemetry mock Co-Authored-By: Claude Sonnet 4.6 --- test/zexbox/auto_escalation_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/zexbox/auto_escalation_test.exs b/test/zexbox/auto_escalation_test.exs index e175214..3a95c70 100644 --- a/test/zexbox/auto_escalation_test.exs +++ b/test/zexbox/auto_escalation_test.exs @@ -41,7 +41,6 @@ defmodule Zexbox.AutoEscalationTest do defp otel_mocks(overrides \\ []) do Keyword.merge( [ - datadog_session_url: fn -> nil end, generate_trace_url: fn -> nil end, kibana_log_url: fn -> nil end ], From c919862aad6012ddf27f4973920d1a96ee1ac03f Mon Sep 17 00:00:00 2001 From: Jez-A Date: Mon, 9 Mar 2026 10:16:16 +0000 Subject: [PATCH 6/6] refactor: Expand handle_error opts to explicit positional arguments Replaces the trailing keyword opts with 5 named positional args with defaults: stacktrace, user_context, additional_context, fingerprint, custom_description. Bumps FunctionArity max_arity to 9 in .credo.exs to accommodate the intentionally explicit public API. Co-Authored-By: Claude Sonnet 4.6 --- .credo.exs | 2 +- lib/zexbox/auto_escalation.ex | 87 +++++++++++++++++++--------- test/zexbox/auto_escalation_test.exs | 30 +++++++--- 3 files changed, 81 insertions(+), 38 deletions(-) diff --git a/.credo.exs b/.credo.exs index bf0d94d..867e2de 100644 --- a/.credo.exs +++ b/.credo.exs @@ -117,7 +117,7 @@ {Credo.Check.Refactor.Apply, []}, {Credo.Check.Refactor.CondStatements, []}, {Credo.Check.Refactor.CyclomaticComplexity, []}, - {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.FunctionArity, [max_arity: 9]}, {Credo.Check.Refactor.LongQuoteBlocks, []}, {Credo.Check.Refactor.MatchInCondition, []}, {Credo.Check.Refactor.MapJoin, []}, diff --git a/lib/zexbox/auto_escalation.ex b/lib/zexbox/auto_escalation.ex index 07cfe57..a95d644 100644 --- a/lib/zexbox/auto_escalation.ex +++ b/lib/zexbox/auto_escalation.ex @@ -22,9 +22,9 @@ defmodule Zexbox.AutoEscalation do "checkout", "High", "Purchase Ops", - stacktrace: __STACKTRACE__, - user_context: %{email: user.email}, - additional_context: %{basket_id: basket.id} + __STACKTRACE__, + %{email: user.email}, + %{basket_id: basket.id} ) end ``` @@ -54,9 +54,9 @@ defmodule Zexbox.AutoEscalation do ``` """ - require Logger + alias Zexbox.{AutoEscalation.AdfBuilder, JiraClient} - alias Zexbox.{JiraClient, AutoEscalation.AdfBuilder} + require Logger defmodule Error do @moduledoc "Raised when Jira ticket creation or transition fails." @@ -80,18 +80,47 @@ defmodule Zexbox.AutoEscalation do - `priority` – Jira priority name (e.g. `"High"`). - `zigl_team` – value for the ZIGL Team custom field. - Optional options: - - `:stacktrace` – pass `__STACKTRACE__` from the rescue block for a full trace. - - `:user_context` – map rendered as a bullet list in the ticket body. - - `:additional_context` – map of extra key/value pairs in the ticket body. - - `:fingerprint` – override deduplication key; defaults to `"action::ErrorClass"`. - - `:custom_description` – string rendered above Error Details (split on `\\n\\n`). + Optional arguments (all default to `nil` or empty): + - `stacktrace` – pass `__STACKTRACE__` from the rescue block for a full trace. + - `user_context` – map rendered as a bullet list in the ticket body. + - `additional_context` – map of extra key/value pairs in the ticket body. + - `fingerprint` – override deduplication key; auto-generated as `"action::ErrorClass"` when `nil`. + - `custom_description` – string rendered above Error Details (split on `\\n\\n`). """ - @spec handle_error(Exception.t(), String.t(), String.t(), String.t(), keyword()) :: - {:ok, map()} | {:error, term()} | {:disabled, nil} - def handle_error(error, action, priority, zigl_team, opts \\ []) do + @spec handle_error( + Exception.t(), + String.t(), + String.t(), + String.t(), + Exception.stacktrace() | nil, + map(), + map(), + String.t() | nil, + String.t() | nil + ) :: {:ok, map()} | {:error, term()} | {:disabled, nil} + def handle_error( + error, + action, + priority, + zigl_team, + stacktrace \\ nil, + user_context \\ %{}, + additional_context \\ %{}, + fingerprint \\ nil, + custom_description \\ nil + ) do if auto_escalation_enabled?() do - do_handle_error(error, action, priority, zigl_team, opts) + do_handle_error( + error, + action, + priority, + zigl_team, + stacktrace, + user_context, + additional_context, + fingerprint, + custom_description + ) else {:disabled, nil} end @@ -110,21 +139,23 @@ defmodule Zexbox.AutoEscalation do # --- Private --- - defp do_handle_error(error, action, priority, zigl_team, opts) do + defp do_handle_error( + error, + action, + priority, + zigl_team, + stacktrace, + user_context, + additional_context, + fingerprint_override, + custom_description + ) do unless is_exception(error) do raise ArgumentError, "Expected an Exception.t() for :error, got: #{inspect(error)}" end - user_context = Keyword.get(opts, :user_context, %{}) - additional_context = Keyword.get(opts, :additional_context, %{}) - stacktrace = Keyword.get(opts, :stacktrace) - custom_description = Keyword.get(opts, :custom_description) - error_class = inspect(error.__struct__) - - fingerprint = - Keyword.get(opts, :fingerprint) || - generate_fingerprint(error_class, action) + fingerprint = fingerprint_override || generate_fingerprint(error_class, action) case find_existing_ticket(fingerprint) do nil -> @@ -166,7 +197,7 @@ defmodule Zexbox.AutoEscalation do {:ok, []} -> nil - {:ok, [first | _]} -> + {:ok, [first | _rest]} -> first {:error, e} -> @@ -215,7 +246,7 @@ defmodule Zexbox.AutoEscalation do priority, custom_fields ), - {:ok, _} <- JiraClient.transition_issue(result["key"], @transition_to) do + {:ok, _resp} <- JiraClient.transition_issue(result["key"], @transition_to) do {:ok, result} else {:error, e} -> @@ -249,7 +280,7 @@ defmodule Zexbox.AutoEscalation do ) case JiraClient.add_comment(issue_key, comment) do - {:ok, _} -> + {:ok, _resp} -> :ok {:error, e} -> diff --git a/test/zexbox/auto_escalation_test.exs b/test/zexbox/auto_escalation_test.exs index 3a95c70..d7ab97d 100644 --- a/test/zexbox/auto_escalation_test.exs +++ b/test/zexbox/auto_escalation_test.exs @@ -122,7 +122,7 @@ defmodule Zexbox.AutoEscalationTest do end end - describe "handle_error/5 — when disabled" do + describe "handle_error/9 — when disabled" do test "returns {:disabled, nil} without calling Jira" do Application.put_env(:zexbox, :auto_escalation_enabled, false) @@ -136,7 +136,7 @@ defmodule Zexbox.AutoEscalationTest do end end - describe "handle_error/5 — new ticket path" do + describe "handle_error/9 — new ticket path" do test "returns {:ok, ticket} and calls create_issue" do with_mocks(all_mocks()) do assert {:ok, ticket} = @@ -217,8 +217,15 @@ defmodule Zexbox.AutoEscalationTest do test "uses custom fingerprint override when provided" do with_mocks(all_mocks(capture_create_issue())) do - AutoEscalation.handle_error(error(), "checkout", "High", "Ops", - fingerprint: "custom::fingerprint" + AutoEscalation.handle_error( + error(), + "checkout", + "High", + "Ops", + nil, + %{}, + %{}, + "custom::fingerprint" ) assert_received {:create_opts, opts} @@ -228,9 +235,14 @@ defmodule Zexbox.AutoEscalationTest do test "description is a valid ADF doc map" do with_mocks(all_mocks(capture_create_issue())) do - AutoEscalation.handle_error(error(), "pay", "Medium", "Payments", - user_context: %{email: "u@example.com"}, - additional_context: %{basket_id: 123} + AutoEscalation.handle_error( + error(), + "pay", + "Medium", + "Payments", + nil, + %{email: "u@example.com"}, + %{basket_id: 123} ) assert_received {:create_opts, opts} @@ -272,7 +284,7 @@ defmodule Zexbox.AutoEscalationTest do end end - describe "handle_error/5 — existing ticket path" do + describe "handle_error/9 — existing ticket path" do test "returns the existing ticket and adds a comment" do jira_overrides = [ search_latest_issues: fn _jql, _project_key -> {:ok, [@existing_ticket]} end @@ -322,7 +334,7 @@ defmodule Zexbox.AutoEscalationTest do end end - describe "handle_error/5 — search failure" do + describe "handle_error/9 — search failure" do test "logs and falls through to create a new ticket when search fails" do jira_overrides = [ search_latest_issues: fn _jql, _project_key -> {:error, "Search failed"} end