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 new file mode 100644 index 0000000..a95d644 --- /dev/null +++ b/lib/zexbox/auto_escalation.ex @@ -0,0 +1,304 @@ +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( + e, + "checkout", + "High", + "Purchase Ops", + __STACKTRACE__, + %{email: user.email}, + %{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 + ``` + """ + + alias Zexbox.{AutoEscalation.AdfBuilder, JiraClient} + + require Logger + + 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 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 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(), + 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, + stacktrace, + user_context, + additional_context, + fingerprint, + custom_description + ) + else + {:disabled, nil} + end + end + + @doc """ + Generates the default deduplication fingerprint for an error. + + ## Examples + + iex> Zexbox.AutoEscalation.generate_fingerprint("StandardError", "checkout") + "checkout::StandardError" + """ + @spec generate_fingerprint(String.t(), String.t()) :: String.t() + def generate_fingerprint(error_class, action), do: "#{action}::#{error_class}" + + # --- Private --- + + 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 + + error_class = inspect(error.__struct__) + fingerprint = fingerprint_override || generate_fingerprint(error_class, action) + + case find_existing_ticket(fingerprint) do + nil -> + create_jira_ticket( + error, + action, + priority, + zigl_team, + fingerprint, + user_context, + additional_context, + custom_description, + stacktrace + ) + + existing_ticket -> + add_comment_to_existing_ticket( + existing_ticket, + error, + action, + user_context, + additional_context, + custom_description, + 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, project_key) do + {:ok, []} -> + nil + + {:ok, [first | _rest]} -> + 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( + error, + action, + priority, + zigl_team, + fingerprint, + user_context, + additional_context, + custom_description, + stacktrace + ) do + 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, + "#{action}: #{error_class}", + description, + @issuetype, + priority, + custom_fields + ), + {:ok, _resp} <- JiraClient.transition_issue(result["key"], @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( + ticket, + error, + action, + user_context, + additional_context, + custom_description, + stacktrace + ) do + 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, comment) do + {:ok, _resp} -> + :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/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"}, diff --git a/test/zexbox/auto_escalation_test.exs b/test/zexbox/auto_escalation_test.exs new file mode 100644 index 0000000..d7ab97d --- /dev/null +++ b/test/zexbox/auto_escalation_test.exs @@ -0,0 +1,357 @@ +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 _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, + zigl_team_field: fn -> %{id: "customfield_10101", name: "ZIGL Team[Dropdown]"} end + ], + overrides + ) + end + + defp otel_mocks(overrides \\ []) do + Keyword.merge( + [ + 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 args that were passed to create_issue. + defp capture_create_issue do + me = self() + + [ + 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 + ] + end + + defp capture_add_comment do + me = self() + + [ + add_comment: fn issue_key, comment -> + send(me, {:comment_opts, {issue_key, comment}}) + {:ok, %{}} + end + ] + end + + defp capture_search do + me = self() + + [ + search_latest_issues: fn jql, _project_key -> + send(me, {:search_jql, jql}) + {: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/2" do + test "returns action::ErrorClass format" do + assert "checkout::StandardError" = + AutoEscalation.generate_fingerprint("StandardError", "checkout") + end + end + + describe "handle_error/9 — 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(), "checkout", "High", "Ops") + + assert result == {:disabled, nil} + refute called(Zexbox.JiraClient.search_latest_issues(:_, :_)) + refute called(Zexbox.JiraClient.create_issue(:_, :_, :_, :_, :_, :_)) + end + end + end + + 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} = + AutoEscalation.handle_error(error(), "checkout", "High", "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(), "checkout", "High", "Purchase Ops") + + assert_received {:create_opts, opts} + assert opts.project_key == "SS" + assert opts.summary == "checkout: RuntimeError" + assert opts.issuetype == "Bug" + assert 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(), "pay", "High", "Payments") + + assert_received {:create_opts, opts} + 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(), "checkout", "High", "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 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(), "checkout", "High", "Ops") + + 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(), "checkout", "High", "Purchase Ops") + + assert_received {:create_opts, opts} + 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(), "checkout", "High", "Purchase Ops") + + assert_received {:create_opts, opts} + 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(), + "checkout", + "High", + "Ops", + nil, + %{}, + %{}, + "custom::fingerprint" + ) + + assert_received {:create_opts, opts} + 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(), + "pay", + "Medium", + "Payments", + nil, + %{email: "u@example.com"}, + %{basket_id: 123} + ) + + assert_received {:create_opts, opts} + 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(), "pay", "High", "Payments") + + assert_received {:search_jql, 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 _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(), "checkout", "High", "Ops") + + assert reason =~ "Failed to create Jira ticket" + end + end) + + assert log =~ "Failed to create Jira ticket" + end + end + + 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 + ] + + with_mocks(all_mocks(jira_overrides)) do + 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(:_, :_)) + end + end + + test "comment targets the existing ticket key" do + jira_overrides = + [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(), "checkout", "High", "Ops") + end + + 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 _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(), "checkout", "High", "Ops") + + assert ticket["key"] == "SS-42" + end + end) + + assert log =~ "Failed to add comment" + end + end + + 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 + ] + + log = + capture_log(fn -> + with_mocks(all_mocks(jira_overrides)) do + assert {:ok, ticket} = + AutoEscalation.handle_error(error(), "checkout", "High", "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