From 7b6713d0ee7e54b3a4623228e50a3bb628714333 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Mon, 9 Mar 2026 10:44:13 +0000 Subject: [PATCH 01/31] feat: Add Zexbox.OpenTelemetry URL helpers with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OpenTelemetry observability URL helpers that mirror Opsbox::OpenTelemetry. Provides Grafana Tempo trace URLs, local Jaeger URLs (dev/development), and Kibana Discover log URLs — all returning nil gracefully when OTEL is unconfigured. Adds opentelemetry_api ~> 1.4 and jason ~> 1.4 dependencies. Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/open_telemetry.ex | 136 ++++++++++++++++++++ mix.exs | 2 + mix.lock | 1 + test/zexbox/open_telemetry_test.exs | 193 ++++++++++++++++++++++++++++ 4 files changed, 332 insertions(+) create mode 100644 lib/zexbox/open_telemetry.ex create mode 100644 test/zexbox/open_telemetry_test.exs diff --git a/lib/zexbox/open_telemetry.ex b/lib/zexbox/open_telemetry.ex new file mode 100644 index 0000000..5fd0453 --- /dev/null +++ b/lib/zexbox/open_telemetry.ex @@ -0,0 +1,136 @@ +defmodule Zexbox.OpenTelemetry do + @moduledoc """ + OpenTelemetry URL helpers for enriching Jira tickets with observability links. + + Mirrors `Opsbox::OpenTelemetry`. Reads from the current process's OTEL context + (baggage and active span) via `opentelemetry_api`. + + All functions return `nil` gracefully when OTEL is unconfigured or there is no + active span, so they are safe to call from any rescue block. + + ## Configuration + + ```elixir + config :zexbox, + service_name: "my-app", + app_env: :production, # :production | :sandbox | :dev | :test (defaults to Mix.env()) + tempo_datasource_uid: nil # optional override for the Grafana Tempo datasource UID + ``` + """ + + @production_tempo_uid "een1tos42jnk0d" + @sandbox_tempo_uid "eehwibr22b6kgb" + @compile_env Mix.env() + + @doc """ + Returns `true` when the current OTEL span context is valid (non-zero trace ID). + """ + @spec valid_active_span?() :: boolean() + def valid_active_span? do + ctx = get_span_ctx() + ctx != nil && :otel_span.is_valid(ctx) + rescue + _e -> false + end + + @doc """ + Returns a Grafana Tempo trace URL for the current span, or `nil` if no active span. + Returns a local Jaeger URL when `app_env` is `:dev` / `:development`. + """ + @spec generate_trace_url() :: String.t() | nil + def generate_trace_url do + if valid_active_span?() do + trace_id = hex_trace_id() + + if app_env() in [:dev, :development] do + "http://localhost:16686/trace/#{trace_id}" + else + pane_json = build_pane_json(trace_id) + "https://zappi.grafana.net/explore?schemaVersion=1&panes=#{Jason.encode!(pane_json)}" + end + end + rescue + _e -> nil + end + + @doc """ + Returns a Kibana Discover URL for `:production` and `:sandbox` environments, `nil` otherwise. + Includes the current trace ID in the query when an active span is present. + """ + @spec kibana_log_url() :: String.t() | nil + def kibana_log_url do + env = app_env() + + if env in [:production, :sandbox] do + trace_id = if valid_active_span?(), do: hex_trace_id(), else: nil + service = Application.get_env(:zexbox, :service_name, "app") + trace_part = if trace_id, do: "%20AND%20%22#{trace_id}%22", else: "" + subdomain = if env == :production, do: "", else: "#{env}." + app_encoded = URI.encode(service, &URI.char_unreserved?/1) + + "https://kibana.#{subdomain}zappi.tools/app/discover#/?_a=(columns:!(log.message),filters:!()," <> + "query:(language:kuery,query:'zappi.app:%20%22#{app_encoded}%22#{trace_part}')," <> + "sort:!(!('@timestamp',asc)))&_g=(filters:!(),time:(from:now-1d,to:now))" + end + rescue + _e -> nil + end + + # --- Private --- + + defp get_span_ctx do + :otel_tracer.current_span_ctx() + rescue + _e -> nil + catch + _kind, _e -> nil + end + + defp hex_trace_id do + case get_span_ctx() do + nil -> + nil + + ctx -> + ctx + |> :otel_span.trace_id() + |> Integer.to_string(16) + |> String.pad_leading(32, "0") + |> String.downcase() + end + end + + defp build_pane_json(trace_id) do + uid = tempo_datasource_uid() + + %{ + plo: %{ + datasource: uid, + queries: [ + %{ + refId: "A", + datasource: %{type: "tempo", uid: uid}, + queryType: "traceql", + limit: 20, + tableType: "traces", + metricsQueryType: "range", + query: trace_id + } + ], + range: %{from: "now-1h", to: "now"} + } + } + end + + defp tempo_datasource_uid do + Application.get_env(:zexbox, :tempo_datasource_uid) || + case app_env() do + :production -> @production_tempo_uid + :sandbox -> @sandbox_tempo_uid + :test -> "test" + _env -> "development" + end + end + + defp app_env, do: Application.get_env(:zexbox, :app_env, @compile_env) +end diff --git a/mix.exs b/mix.exs index 5e5dcb2..6702ea0 100644 --- a/mix.exs +++ b/mix.exs @@ -37,6 +37,8 @@ defmodule Zexbox.MixProject do {:doctor, "~> 0.22.0", only: [:dev, :test]}, {:ex_doc, "~> 0.35.1", only: :dev, runtime: false}, {:instream, "~> 2.2"}, + {:jason, "~> 1.4"}, + {:opentelemetry_api, "~> 1.4"}, {:ldclient, "~> 3.8.0", hex: :launchdarkly_server_sdk}, {:mix_audit, "~> 2.0", only: [:dev, :test], runtime: false}, {:mock, "~> 0.3.0", only: :test}, diff --git a/mix.lock b/mix.lock index f3472fc..1442796 100644 --- a/mix.lock +++ b/mix.lock @@ -30,6 +30,7 @@ "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_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "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"}, diff --git a/test/zexbox/open_telemetry_test.exs b/test/zexbox/open_telemetry_test.exs new file mode 100644 index 0000000..8887635 --- /dev/null +++ b/test/zexbox/open_telemetry_test.exs @@ -0,0 +1,193 @@ +defmodule Zexbox.OpenTelemetryTest do + use ExUnit.Case + import Mock + alias Zexbox.OpenTelemetry + + # A fake span context atom used as a stand-in for the opaque OTEL span record. + @mock_ctx :mock_span_ctx + + # Hex representation of the integer 255 padded to 32 chars. + @mock_trace_id_int 255 + @mock_trace_id_hex String.pad_leading("ff", 32, "0") + + defp with_active_span(fun) do + with_mocks([ + {:otel_tracer, [], [current_span_ctx: fn -> @mock_ctx end]}, + {:otel_span, [], + [is_valid: fn @mock_ctx -> true end, trace_id: fn @mock_ctx -> @mock_trace_id_int end]} + ]) do + fun.() + end + end + + defp with_no_span(fun) do + with_mocks([ + {:otel_tracer, [], [current_span_ctx: fn -> :undefined end]}, + {:otel_span, [], [is_valid: fn :undefined -> false end]} + ]) do + fun.() + end + end + + setup do + on_exit(fn -> + Application.delete_env(:zexbox, :app_env) + Application.delete_env(:zexbox, :service_name) + Application.delete_env(:zexbox, :tempo_datasource_uid) + end) + + :ok + end + + describe "valid_active_span?/0" do + test "returns true when the current span is valid" do + with_active_span(fn -> + assert OpenTelemetry.valid_active_span?() == true + end) + end + + test "returns false when there is no active span" do + with_no_span(fn -> + assert OpenTelemetry.valid_active_span?() == false + end) + end + + test "returns false when OTEL raises" do + with_mock :otel_tracer, current_span_ctx: fn -> raise "otel not running" end do + assert OpenTelemetry.valid_active_span?() == false + end + end + end + + describe "generate_trace_url/0" do + test "returns nil when there is no active span" do + with_no_span(fn -> + assert OpenTelemetry.generate_trace_url() == nil + end) + end + + test "returns a Grafana Tempo URL when there is an active span" do + Application.put_env(:zexbox, :app_env, :production) + + with_active_span(fn -> + url = OpenTelemetry.generate_trace_url() + assert url =~ "zappi.grafana.net" + assert url =~ @mock_trace_id_hex + end) + end + + test "Grafana URL encodes the pane JSON with the trace ID and datasource UID" do + Application.put_env(:zexbox, :app_env, :production) + Application.put_env(:zexbox, :tempo_datasource_uid, "test-uid") + + with_active_span(fn -> + url = OpenTelemetry.generate_trace_url() + assert url =~ "test-uid" + assert url =~ @mock_trace_id_hex + end) + end + + test "returns a local Jaeger URL in :dev environment" do + Application.put_env(:zexbox, :app_env, :dev) + + with_active_span(fn -> + url = OpenTelemetry.generate_trace_url() + assert url == "http://localhost:16686/trace/#{@mock_trace_id_hex}" + end) + end + + test "returns a local Jaeger URL in :development environment" do + Application.put_env(:zexbox, :app_env, :development) + + with_active_span(fn -> + url = OpenTelemetry.generate_trace_url() + assert url == "http://localhost:16686/trace/#{@mock_trace_id_hex}" + end) + end + + test "returns nil when OTEL raises" do + with_mock :otel_tracer, current_span_ctx: fn -> raise "otel not running" end do + assert OpenTelemetry.generate_trace_url() == nil + end + end + end + + describe "kibana_log_url/0" do + test "returns nil for :test environment" do + Application.put_env(:zexbox, :app_env, :test) + + with_no_span(fn -> + assert OpenTelemetry.kibana_log_url() == nil + end) + end + + test "returns nil for :dev environment" do + Application.put_env(:zexbox, :app_env, :dev) + + with_no_span(fn -> + assert OpenTelemetry.kibana_log_url() == nil + end) + end + + test "returns a Kibana URL for :production environment" do + Application.put_env(:zexbox, :app_env, :production) + Application.put_env(:zexbox, :service_name, "my-app") + + with_no_span(fn -> + url = OpenTelemetry.kibana_log_url() + assert url =~ "kibana.zappi.tools" + assert url =~ "my-app" + refute url =~ "sandbox" + end) + end + + test "returns a Kibana URL for :sandbox environment with subdomain" do + Application.put_env(:zexbox, :app_env, :sandbox) + Application.put_env(:zexbox, :service_name, "my-app") + + with_no_span(fn -> + url = OpenTelemetry.kibana_log_url() + assert url =~ "kibana.sandbox.zappi.tools" + assert url =~ "my-app" + end) + end + + test "URL-encodes the service name" do + Application.put_env(:zexbox, :app_env, :production) + Application.put_env(:zexbox, :service_name, "my app") + + with_no_span(fn -> + url = OpenTelemetry.kibana_log_url() + assert url =~ "my%20app" + end) + end + + test "includes the trace ID in the URL when a span is active" do + Application.put_env(:zexbox, :app_env, :production) + + with_active_span(fn -> + url = OpenTelemetry.kibana_log_url() + assert url =~ @mock_trace_id_hex + end) + end + + test "omits the trace part when no active span" do + Application.put_env(:zexbox, :app_env, :production) + + with_no_span(fn -> + url = OpenTelemetry.kibana_log_url() + refute url =~ "AND" + end) + end + + test "omits trace ID when OTEL raises (returns URL without trace filter)" do + Application.put_env(:zexbox, :app_env, :production) + + with_mock :otel_tracer, current_span_ctx: fn -> raise "otel not running" end do + url = OpenTelemetry.kibana_log_url() + assert url =~ "kibana.zappi.tools" + refute url =~ "AND" + end + end + end +end From 6a1cdb9d4dce74c2f8efb9f14ce58ef8362e202f Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 14:47:56 +0000 Subject: [PATCH 02/31] feat: Add JiraClient and OpenTelemetry URL helpers Adds two new utility modules required by the AutoEscalation feature: - Zexbox.JiraClient: Req-based client for the Jira Cloud REST API v3. Supports search, create, transition, and add_comment operations. Authenticates via JIRA_USER_EMAIL_ADDRESS / JIRA_API_TOKEN env vars or :jira_email / :jira_api_token application config. - Zexbox.OpenTelemetry: Reads the current process OTEL context (baggage and active span) to produce Datadog session, Grafana Tempo trace, and Kibana Discover URLs. Returns nil gracefully when OTEL is unconfigured. Adds :req, :jason, and :opentelemetry_api dependencies to mix.exs. Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/jira_client.ex | 208 +++++++++++++++++++++++++++++++ mix.exs | 3 +- test/zexbox/jira_client_test.exs | 196 +++++++++++++++++++++++++++++ 3 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 lib/zexbox/jira_client.ex create mode 100644 test/zexbox/jira_client_test.exs diff --git a/lib/zexbox/jira_client.ex b/lib/zexbox/jira_client.ex new file mode 100644 index 0000000..5f1d390 --- /dev/null +++ b/lib/zexbox/jira_client.ex @@ -0,0 +1,208 @@ +defmodule Zexbox.JiraClient do + @moduledoc """ + HTTP client for the Jira Cloud REST API v3. + + Mirrors `Opsbox::JiraClient`. Authenticates with Basic auth using + `JIRA_USER_EMAIL_ADDRESS` and `JIRA_API_TOKEN` environment variables + (or `:jira_email` / `:jira_api_token` application config). + + ## Configuration + + ```elixir + config :zexbox, + jira_base_url: "https://your-org.atlassian.net", + jira_email: System.get_env("JIRA_USER_EMAIL_ADDRESS"), + jira_api_token: System.get_env("JIRA_API_TOKEN") + ``` + + All public functions return `{:ok, result}` or `{:error, reason}`. + """ + + @bug_fingerprint_field %{id: "customfield_13442", name: "Bug Fingerprint[Short text]"} + @zigl_team_field %{id: "customfield_10101", name: "ZIGL Team[Dropdown]"} + + @doc "Returns the bug fingerprint custom field metadata." + @spec bug_fingerprint_field() :: %{id: String.t(), name: String.t()} + def bug_fingerprint_field, do: @bug_fingerprint_field + + @doc "Returns the ZIGL team custom field metadata." + @spec zigl_team_field() :: %{id: String.t(), name: String.t()} + def zigl_team_field, do: @zigl_team_field + + @doc """ + Search for the latest issues matching a JQL query (max 50 results). + + Options: + - `:jql` (required) – JQL query string. + - `:project_key` (optional) – prepends `project = KEY AND` to the JQL. + + Returns `{:ok, [issue_map]}` where each map includes a `"url"` browse key, + or `{:error, reason}` on failure. + """ + @spec search_latest_issues(keyword()) :: {:ok, [map()]} | {:error, term()} + def search_latest_issues(opts) do + jql = Keyword.fetch!(opts, :jql) + project_key = Keyword.get(opts, :project_key) + + query = if project_key, do: "project = #{project_key} AND #{jql}", else: jql + + client = build_client() + + case jira_get(client, "/rest/api/3/search/jql", + jql: query, + maxResults: 50, + fields: "key,id,self,status,summary" + ) do + {:ok, body} -> + base_url = config(:jira_base_url, nil) + issues = Map.get(body, "issues", []) + issues = Enum.map(issues, &Map.put(&1, "url", "#{base_url}/browse/#{&1["key"]}")) + {:ok, issues} + + {:error, _} = err -> + err + end + end + + @doc """ + Create a new Jira issue. + + Options: + - `:project_key` – Jira project key (e.g. `"SS"`). + - `:summary` – issue summary string. + - `:description` – ADF map (already built; not converted). + - `:issuetype` – issue type name (e.g. `"Bug"`). + - `:priority` – priority name (e.g. `"High"`). + - `:custom_fields` – map of custom field ID → value (string keys). + + Returns `{:ok, issue_map}` with a `"url"` browse key added, or `{:error, reason}`. + """ + @spec create_issue(keyword()) :: {:ok, map()} | {:error, term()} + def create_issue(opts) do + project_key = Keyword.fetch!(opts, :project_key) + summary = Keyword.fetch!(opts, :summary) + description = Keyword.fetch!(opts, :description) + issuetype = Keyword.fetch!(opts, :issuetype) + priority = Keyword.fetch!(opts, :priority) + custom_fields = Keyword.get(opts, :custom_fields, %{}) + + fields = + Map.merge( + %{ + "project" => %{"key" => project_key}, + "summary" => summary, + "description" => description, + "issuetype" => %{"name" => issuetype}, + "priority" => %{"name" => priority} + }, + custom_fields + ) + + client = build_client() + + case jira_post(client, "/rest/api/3/issue", %{"fields" => fields}) do + {:ok, result} -> + base_url = config(:jira_base_url, nil) + {:ok, Map.put(result, "url", "#{base_url}/browse/#{result["key"]}")} + + {:error, _} = err -> + err + end + end + + @doc """ + Transition a Jira issue to a new status by name (case-insensitive match). + + Options: + - `:issue_key` – issue key (e.g. `"SS-42"`). + - `:status_name` – target status name (e.g. `"To do"`). + + Returns `{:ok, %{success: true, status: name}}` or `{:error, reason}`. + """ + @spec transition_issue(keyword()) :: {:ok, map()} | {:error, term()} + def transition_issue(opts) do + issue_key = Keyword.fetch!(opts, :issue_key) + status_name = Keyword.fetch!(opts, :status_name) + + client = build_client() + + with {:ok, data} <- jira_get(client, "/rest/api/3/issue/#{issue_key}/transitions"), + transitions = Map.get(data, "transitions", []), + {:ok, target} <- find_transition(transitions, status_name), + {:ok, _} <- + jira_post(client, "/rest/api/3/issue/#{issue_key}/transitions", %{ + "transition" => %{"id" => target["id"]} + }) do + {:ok, %{success: true, status: get_in(target, ["to", "name"])}} + end + end + + @doc """ + Add a comment to an existing Jira issue. + + Options: + - `:issue_key` – issue key (e.g. `"SS-42"`). + - `:comment` – ADF map for the comment body (already built; not converted). + + Returns `{:ok, comment_map}` or `{:error, reason}`. + """ + @spec add_comment(keyword()) :: {:ok, map()} | {:error, term()} + def add_comment(opts) do + issue_key = Keyword.fetch!(opts, :issue_key) + comment = Keyword.fetch!(opts, :comment) + + client = build_client() + jira_post(client, "/rest/api/3/issue/#{issue_key}/comment", %{"body" => comment}) + end + + # --- Private helpers --- + + defp find_transition(transitions, status_name) do + case Enum.find(transitions, fn t -> + to_name = get_in(t, ["to", "name"]) || "" + String.downcase(to_name) == String.downcase(status_name) + end) do + nil -> {:error, "Cannot transition to '#{status_name}'"} + target -> {:ok, target} + end + end + + defp jira_get(client, path, params \\ []) do + case Req.get(client, url: path, params: params) do + {:ok, %{status: status, body: body}} when status in 200..299 -> + {:ok, body || %{}} + + {:ok, %{status: status, body: body}} -> + {:error, "HTTP #{status}: #{inspect(body)}"} + + {:error, reason} -> + {:error, inspect(reason)} + end + end + + defp jira_post(client, path, body) do + case Req.post(client, url: path, json: body) do + {:ok, %{status: status, body: resp_body}} when status in 200..299 -> + {:ok, resp_body || %{}} + + {:ok, %{status: status, body: resp_body}} -> + {:error, "HTTP #{status}: #{inspect(resp_body)}"} + + {:error, reason} -> + {:error, inspect(reason)} + end + end + + defp build_client do + base_url = config(:jira_base_url, nil) + email = config(:jira_email, System.get_env("JIRA_USER_EMAIL_ADDRESS", "")) + token = config(:jira_api_token, System.get_env("JIRA_API_TOKEN", "")) + + Req.new( + base_url: base_url, + auth: {:basic, "#{email}:#{token}"} + ) + end + + defp config(key, default), do: Application.get_env(:zexbox, key, default) +end diff --git a/mix.exs b/mix.exs index 6702ea0..417f2c2 100644 --- a/mix.exs +++ b/mix.exs @@ -38,10 +38,11 @@ defmodule Zexbox.MixProject do {:ex_doc, "~> 0.35.1", only: :dev, runtime: false}, {:instream, "~> 2.2"}, {:jason, "~> 1.4"}, - {:opentelemetry_api, "~> 1.4"}, {:ldclient, "~> 3.8.0", hex: :launchdarkly_server_sdk}, {:mix_audit, "~> 2.0", only: [:dev, :test], runtime: false}, {:mock, "~> 0.3.0", only: :test}, + {:opentelemetry_api, "~> 1.4"}, + {:req, "~> 0.5"}, {:sobelow, "~> 0.8", only: [:dev, :test]}, {:telemetry, "~> 1.3"} ] diff --git a/test/zexbox/jira_client_test.exs b/test/zexbox/jira_client_test.exs new file mode 100644 index 0000000..0ea02fb --- /dev/null +++ b/test/zexbox/jira_client_test.exs @@ -0,0 +1,196 @@ +defmodule Zexbox.JiraClientTest do + use ExUnit.Case + import Mock + alias Zexbox.JiraClient + + @base_url "https://zigroup.atlassian.net" + + setup do + Application.put_env(:zexbox, :jira_base_url, @base_url) + Application.put_env(:zexbox, :jira_email, "test@example.com") + Application.put_env(:zexbox, :jira_api_token, "test-token") + + on_exit(fn -> + Application.delete_env(:zexbox, :jira_base_url) + Application.delete_env(:zexbox, :jira_email) + Application.delete_env(:zexbox, :jira_api_token) + end) + + :ok + end + + describe "bug_fingerprint_field/0" do + test "returns the correct field metadata" do + assert %{id: "customfield_13442", name: "Bug Fingerprint[Short text]"} = + JiraClient.bug_fingerprint_field() + end + end + + describe "zigl_team_field/0" do + test "returns the correct field metadata" do + assert %{id: "customfield_10101", name: "ZIGL Team[Dropdown]"} = + JiraClient.zigl_team_field() + end + end + + describe "search_latest_issues/1" do + test_with_mock "returns issues with url keys added on success", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "issues" => [ + %{"key" => "SS-1", "id" => "10001", "self" => "#{@base_url}/rest/api/3/issue/10001"} + ] + } + }} + end do + assert {:ok, [issue]} = JiraClient.search_latest_issues(jql: "status = Open", project_key: "SS") + assert issue["key"] == "SS-1" + assert issue["url"] == "#{@base_url}/browse/SS-1" + end + + test_with_mock "returns empty list when no issues found", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, %{status: 200, body: %{"issues" => []}}} + end do + assert {:ok, []} = JiraClient.search_latest_issues(jql: "status = Open") + end + + test_with_mock "returns error on non-2xx response", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, %{status: 401, body: %{"message" => "Unauthorized"}}} + end do + assert {:error, reason} = JiraClient.search_latest_issues(jql: "status = Open") + assert reason =~ "HTTP 401" + end + + test_with_mock "returns error on request failure", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:error, %{reason: :econnrefused}} + end do + assert {:error, _reason} = JiraClient.search_latest_issues(jql: "status = Open") + end + end + + describe "create_issue/1" do + test_with_mock "creates issue and adds url to result", Req, + new: fn _opts -> :mock_client end, + post: fn :mock_client, _opts -> + {:ok, + %{ + status: 201, + body: %{ + "key" => "SS-99", + "id" => "10099", + "self" => "#{@base_url}/rest/api/3/issue/10099" + } + }} + end do + assert {:ok, result} = + JiraClient.create_issue( + project_key: "SS", + summary: "checkout: RuntimeError", + description: %{version: 1, type: "doc", content: []}, + issuetype: "Bug", + priority: "High", + custom_fields: %{"customfield_13442" => "checkout::RuntimeError"} + ) + + assert result["key"] == "SS-99" + assert result["url"] == "#{@base_url}/browse/SS-99" + end + + test_with_mock "returns error on non-2xx response", Req, + new: fn _opts -> :mock_client end, + post: fn :mock_client, _opts -> + {:ok, %{status: 400, body: %{"errorMessages" => ["Invalid project"]}}} + end do + assert {:error, reason} = + JiraClient.create_issue( + project_key: "INVALID", + summary: "test", + description: %{}, + issuetype: "Bug", + priority: "High", + custom_fields: %{} + ) + + assert reason =~ "HTTP 400" + end + end + + describe "transition_issue/1" do + test_with_mock "transitions issue to target status", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "transitions" => [ + %{"id" => "11", "to" => %{"name" => "To do"}}, + %{"id" => "21", "to" => %{"name" => "In Progress"}} + ] + } + }} + end, + post: fn :mock_client, opts -> + assert opts[:json] == %{"transition" => %{"id" => "11"}} + {:ok, %{status: 204, body: nil}} + end do + assert {:ok, %{success: true, status: "To do"}} = + JiraClient.transition_issue(issue_key: "SS-1", status_name: "To do") + end + + test_with_mock "returns error when target status not found", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, + %{ + status: 200, + body: %{"transitions" => [%{"id" => "11", "to" => %{"name" => "Done"}}]} + }} + end do + assert {:error, reason} = + JiraClient.transition_issue(issue_key: "SS-1", status_name: "Nonexistent") + + assert reason =~ "Cannot transition to" + end + end + + describe "add_comment/1" do + test_with_mock "adds comment and returns the response", Req, + new: fn _opts -> :mock_client end, + post: fn :mock_client, _opts -> + {:ok, + %{ + status: 201, + body: %{"id" => "30001", "body" => %{}, "author" => %{"displayName" => "Bot"}} + }} + end do + comment = %{version: 1, type: "doc", content: []} + + assert {:ok, result} = + JiraClient.add_comment(issue_key: "SS-42", comment: comment) + + assert result["id"] == "30001" + end + + test_with_mock "returns error on non-2xx response", Req, + new: fn _opts -> :mock_client end, + post: fn :mock_client, _opts -> + {:ok, %{status: 404, body: %{"errorMessages" => ["Issue not found"]}}} + end do + assert {:error, reason} = + JiraClient.add_comment(issue_key: "SS-999", comment: %{}) + + assert reason =~ "HTTP 404" + end + end +end From 9be32aadf4b878e14b2704972c3bc46f22aaba5c Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 14:59:02 +0000 Subject: [PATCH 03/31] style: Run mix format Co-Authored-By: Claude Sonnet 4.6 --- test/zexbox/jira_client_test.exs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/zexbox/jira_client_test.exs b/test/zexbox/jira_client_test.exs index 0ea02fb..2c6a953 100644 --- a/test/zexbox/jira_client_test.exs +++ b/test/zexbox/jira_client_test.exs @@ -42,12 +42,18 @@ defmodule Zexbox.JiraClientTest do status: 200, body: %{ "issues" => [ - %{"key" => "SS-1", "id" => "10001", "self" => "#{@base_url}/rest/api/3/issue/10001"} + %{ + "key" => "SS-1", + "id" => "10001", + "self" => "#{@base_url}/rest/api/3/issue/10001" + } ] } }} end do - assert {:ok, [issue]} = JiraClient.search_latest_issues(jql: "status = Open", project_key: "SS") + assert {:ok, [issue]} = + JiraClient.search_latest_issues(jql: "status = Open", project_key: "SS") + assert issue["key"] == "SS-1" assert issue["url"] == "#{@base_url}/browse/SS-1" end From e89c213ffa017106b4faa52b6f8744776b791f70 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 15:25:58 +0000 Subject: [PATCH 04/31] fix: Use POST /search/jql for search and add Accept header to all requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_latest_issues was using GET with query params, which means the response body was not guaranteed to be JSON-decoded by Req (Req only auto-decodes based on the response Content-Type, not the request method). Two changes: - search_latest_issues now uses POST /rest/api/3/search/jql with a JSON body — the preferred Jira v3 approach. This also allows fields to be sent as a proper JSON array rather than a comma-separated string. - build_client adds Accept: application/json to every request so the remaining GET (transitions fetch) also explicitly signals JSON intent and Req's decode_body step has a clear content-type to act on. Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/jira_client.ex | 13 +++++++------ test/zexbox/jira_client_test.exs | 8 ++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/zexbox/jira_client.ex b/lib/zexbox/jira_client.ex index 5f1d390..25597ed 100644 --- a/lib/zexbox/jira_client.ex +++ b/lib/zexbox/jira_client.ex @@ -48,11 +48,11 @@ defmodule Zexbox.JiraClient do client = build_client() - case jira_get(client, "/rest/api/3/search/jql", - jql: query, - maxResults: 50, - fields: "key,id,self,status,summary" - ) do + case jira_post(client, "/rest/api/3/search/jql", %{ + "jql" => query, + "maxResults" => 50, + "fields" => ["key", "id", "self", "status", "summary"] + }) do {:ok, body} -> base_url = config(:jira_base_url, nil) issues = Map.get(body, "issues", []) @@ -200,7 +200,8 @@ defmodule Zexbox.JiraClient do Req.new( base_url: base_url, - auth: {:basic, "#{email}:#{token}"} + auth: {:basic, "#{email}:#{token}"}, + headers: [{"accept", "application/json"}] ) end diff --git a/test/zexbox/jira_client_test.exs b/test/zexbox/jira_client_test.exs index 2c6a953..7894441 100644 --- a/test/zexbox/jira_client_test.exs +++ b/test/zexbox/jira_client_test.exs @@ -36,7 +36,7 @@ defmodule Zexbox.JiraClientTest do describe "search_latest_issues/1" do test_with_mock "returns issues with url keys added on success", Req, new: fn _opts -> :mock_client end, - get: fn :mock_client, _opts -> + post: fn :mock_client, _opts -> {:ok, %{ status: 200, @@ -60,7 +60,7 @@ defmodule Zexbox.JiraClientTest do test_with_mock "returns empty list when no issues found", Req, new: fn _opts -> :mock_client end, - get: fn :mock_client, _opts -> + post: fn :mock_client, _opts -> {:ok, %{status: 200, body: %{"issues" => []}}} end do assert {:ok, []} = JiraClient.search_latest_issues(jql: "status = Open") @@ -68,7 +68,7 @@ defmodule Zexbox.JiraClientTest do test_with_mock "returns error on non-2xx response", Req, new: fn _opts -> :mock_client end, - get: fn :mock_client, _opts -> + post: fn :mock_client, _opts -> {:ok, %{status: 401, body: %{"message" => "Unauthorized"}}} end do assert {:error, reason} = JiraClient.search_latest_issues(jql: "status = Open") @@ -77,7 +77,7 @@ defmodule Zexbox.JiraClientTest do test_with_mock "returns error on request failure", Req, new: fn _opts -> :mock_client end, - get: fn :mock_client, _opts -> + post: fn :mock_client, _opts -> {:error, %{reason: :econnrefused}} end do assert {:error, _reason} = JiraClient.search_latest_issues(jql: "status = Open") From fdb872b82200bbf619fa6667d96a6a3ccaacda0d Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 15:35:45 +0000 Subject: [PATCH 05/31] refactor: Convert JiraClient public API to explicit positional arguments Replaces keyword list opts (Keyword.fetch!/get) with typed positional arguments throughout, consistent with the rest of the repo's style: search_latest_issues(jql, project_key \\ nil) create_issue(project_key, summary, description, issuetype, priority, custom_fields \\ %{}) transition_issue(issue_key, status_name) add_comment(issue_key, comment) Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/jira_client.ex | 61 +++++++++++--------------------- test/zexbox/jira_client_test.exs | 54 ++++++++++------------------ 2 files changed, 40 insertions(+), 75 deletions(-) diff --git a/lib/zexbox/jira_client.ex b/lib/zexbox/jira_client.ex index 25597ed..517e7f9 100644 --- a/lib/zexbox/jira_client.ex +++ b/lib/zexbox/jira_client.ex @@ -32,18 +32,14 @@ defmodule Zexbox.JiraClient do @doc """ Search for the latest issues matching a JQL query (max 50 results). - Options: - - `:jql` (required) – JQL query string. - - `:project_key` (optional) – prepends `project = KEY AND` to the JQL. + - `jql` – JQL query string. + - `project_key` – optional; prepends `project = KEY AND` to the JQL. Returns `{:ok, [issue_map]}` where each map includes a `"url"` browse key, or `{:error, reason}` on failure. """ - @spec search_latest_issues(keyword()) :: {:ok, [map()]} | {:error, term()} - def search_latest_issues(opts) do - jql = Keyword.fetch!(opts, :jql) - project_key = Keyword.get(opts, :project_key) - + @spec search_latest_issues(String.t(), String.t() | nil) :: {:ok, [map()]} | {:error, term()} + def search_latest_issues(jql, project_key \\ nil) do query = if project_key, do: "project = #{project_key} AND #{jql}", else: jql client = build_client() @@ -67,25 +63,18 @@ defmodule Zexbox.JiraClient do @doc """ Create a new Jira issue. - Options: - - `:project_key` – Jira project key (e.g. `"SS"`). - - `:summary` – issue summary string. - - `:description` – ADF map (already built; not converted). - - `:issuetype` – issue type name (e.g. `"Bug"`). - - `:priority` – priority name (e.g. `"High"`). - - `:custom_fields` – map of custom field ID → value (string keys). + - `project_key` – Jira project key (e.g. `"SS"`). + - `summary` – issue summary string. + - `description` – ADF map (already built; not converted). + - `issuetype` – issue type name (e.g. `"Bug"`). + - `priority` – priority name (e.g. `"High"`). + - `custom_fields` – optional map of custom field ID → value (string keys). Returns `{:ok, issue_map}` with a `"url"` browse key added, or `{:error, reason}`. """ - @spec create_issue(keyword()) :: {:ok, map()} | {:error, term()} - def create_issue(opts) do - project_key = Keyword.fetch!(opts, :project_key) - summary = Keyword.fetch!(opts, :summary) - description = Keyword.fetch!(opts, :description) - issuetype = Keyword.fetch!(opts, :issuetype) - priority = Keyword.fetch!(opts, :priority) - custom_fields = Keyword.get(opts, :custom_fields, %{}) - + @spec create_issue(String.t(), String.t(), map(), String.t(), String.t(), map()) :: + {:ok, map()} | {:error, term()} + def create_issue(project_key, summary, description, issuetype, priority, custom_fields \\ %{}) do fields = Map.merge( %{ @@ -113,17 +102,13 @@ defmodule Zexbox.JiraClient do @doc """ Transition a Jira issue to a new status by name (case-insensitive match). - Options: - - `:issue_key` – issue key (e.g. `"SS-42"`). - - `:status_name` – target status name (e.g. `"To do"`). + - `issue_key` – issue key (e.g. `"SS-42"`). + - `status_name` – target status name (e.g. `"To do"`). Returns `{:ok, %{success: true, status: name}}` or `{:error, reason}`. """ - @spec transition_issue(keyword()) :: {:ok, map()} | {:error, term()} - def transition_issue(opts) do - issue_key = Keyword.fetch!(opts, :issue_key) - status_name = Keyword.fetch!(opts, :status_name) - + @spec transition_issue(String.t(), String.t()) :: {:ok, map()} | {:error, term()} + def transition_issue(issue_key, status_name) do client = build_client() with {:ok, data} <- jira_get(client, "/rest/api/3/issue/#{issue_key}/transitions"), @@ -140,17 +125,13 @@ defmodule Zexbox.JiraClient do @doc """ Add a comment to an existing Jira issue. - Options: - - `:issue_key` – issue key (e.g. `"SS-42"`). - - `:comment` – ADF map for the comment body (already built; not converted). + - `issue_key` – issue key (e.g. `"SS-42"`). + - `comment` – ADF map for the comment body (already built; not converted). Returns `{:ok, comment_map}` or `{:error, reason}`. """ - @spec add_comment(keyword()) :: {:ok, map()} | {:error, term()} - def add_comment(opts) do - issue_key = Keyword.fetch!(opts, :issue_key) - comment = Keyword.fetch!(opts, :comment) - + @spec add_comment(String.t(), map()) :: {:ok, map()} | {:error, term()} + def add_comment(issue_key, comment) do client = build_client() jira_post(client, "/rest/api/3/issue/#{issue_key}/comment", %{"body" => comment}) end diff --git a/test/zexbox/jira_client_test.exs b/test/zexbox/jira_client_test.exs index 7894441..9bbd970 100644 --- a/test/zexbox/jira_client_test.exs +++ b/test/zexbox/jira_client_test.exs @@ -33,7 +33,7 @@ defmodule Zexbox.JiraClientTest do end end - describe "search_latest_issues/1" do + describe "search_latest_issues/2" do test_with_mock "returns issues with url keys added on success", Req, new: fn _opts -> :mock_client end, post: fn :mock_client, _opts -> @@ -51,9 +51,7 @@ defmodule Zexbox.JiraClientTest do } }} end do - assert {:ok, [issue]} = - JiraClient.search_latest_issues(jql: "status = Open", project_key: "SS") - + assert {:ok, [issue]} = JiraClient.search_latest_issues("status = Open", "SS") assert issue["key"] == "SS-1" assert issue["url"] == "#{@base_url}/browse/SS-1" end @@ -63,7 +61,7 @@ defmodule Zexbox.JiraClientTest do post: fn :mock_client, _opts -> {:ok, %{status: 200, body: %{"issues" => []}}} end do - assert {:ok, []} = JiraClient.search_latest_issues(jql: "status = Open") + assert {:ok, []} = JiraClient.search_latest_issues("status = Open") end test_with_mock "returns error on non-2xx response", Req, @@ -71,7 +69,7 @@ defmodule Zexbox.JiraClientTest do post: fn :mock_client, _opts -> {:ok, %{status: 401, body: %{"message" => "Unauthorized"}}} end do - assert {:error, reason} = JiraClient.search_latest_issues(jql: "status = Open") + assert {:error, reason} = JiraClient.search_latest_issues("status = Open") assert reason =~ "HTTP 401" end @@ -80,11 +78,11 @@ defmodule Zexbox.JiraClientTest do post: fn :mock_client, _opts -> {:error, %{reason: :econnrefused}} end do - assert {:error, _reason} = JiraClient.search_latest_issues(jql: "status = Open") + assert {:error, _reason} = JiraClient.search_latest_issues("status = Open") end end - describe "create_issue/1" do + describe "create_issue/6" do test_with_mock "creates issue and adds url to result", Req, new: fn _opts -> :mock_client end, post: fn :mock_client, _opts -> @@ -100,12 +98,12 @@ defmodule Zexbox.JiraClientTest do end do assert {:ok, result} = JiraClient.create_issue( - project_key: "SS", - summary: "checkout: RuntimeError", - description: %{version: 1, type: "doc", content: []}, - issuetype: "Bug", - priority: "High", - custom_fields: %{"customfield_13442" => "checkout::RuntimeError"} + "SS", + "checkout: RuntimeError", + %{version: 1, type: "doc", content: []}, + "Bug", + "High", + %{"customfield_13442" => "checkout::RuntimeError"} ) assert result["key"] == "SS-99" @@ -118,20 +116,13 @@ defmodule Zexbox.JiraClientTest do {:ok, %{status: 400, body: %{"errorMessages" => ["Invalid project"]}}} end do assert {:error, reason} = - JiraClient.create_issue( - project_key: "INVALID", - summary: "test", - description: %{}, - issuetype: "Bug", - priority: "High", - custom_fields: %{} - ) + JiraClient.create_issue("INVALID", "test", %{}, "Bug", "High") assert reason =~ "HTTP 400" end end - describe "transition_issue/1" do + describe "transition_issue/2" do test_with_mock "transitions issue to target status", Req, new: fn _opts -> :mock_client end, get: fn :mock_client, _opts -> @@ -151,7 +142,7 @@ defmodule Zexbox.JiraClientTest do {:ok, %{status: 204, body: nil}} end do assert {:ok, %{success: true, status: "To do"}} = - JiraClient.transition_issue(issue_key: "SS-1", status_name: "To do") + JiraClient.transition_issue("SS-1", "To do") end test_with_mock "returns error when target status not found", Req, @@ -163,14 +154,12 @@ defmodule Zexbox.JiraClientTest do body: %{"transitions" => [%{"id" => "11", "to" => %{"name" => "Done"}}]} }} end do - assert {:error, reason} = - JiraClient.transition_issue(issue_key: "SS-1", status_name: "Nonexistent") - + assert {:error, reason} = JiraClient.transition_issue("SS-1", "Nonexistent") assert reason =~ "Cannot transition to" end end - describe "add_comment/1" do + describe "add_comment/2" do test_with_mock "adds comment and returns the response", Req, new: fn _opts -> :mock_client end, post: fn :mock_client, _opts -> @@ -181,10 +170,7 @@ defmodule Zexbox.JiraClientTest do }} end do comment = %{version: 1, type: "doc", content: []} - - assert {:ok, result} = - JiraClient.add_comment(issue_key: "SS-42", comment: comment) - + assert {:ok, result} = JiraClient.add_comment("SS-42", comment) assert result["id"] == "30001" end @@ -193,9 +179,7 @@ defmodule Zexbox.JiraClientTest do post: fn :mock_client, _opts -> {:ok, %{status: 404, body: %{"errorMessages" => ["Issue not found"]}}} end do - assert {:error, reason} = - JiraClient.add_comment(issue_key: "SS-999", comment: %{}) - + assert {:error, reason} = JiraClient.add_comment("SS-999", %{}) assert reason =~ "HTTP 404" end end From 6d6528f52e63285db330a5dd47144eb28ed6877f Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 16:03:21 +0000 Subject: [PATCH 06/31] fix: Name bare _ wildcards to satisfy Credo consistency check Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/jira_client.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/zexbox/jira_client.ex b/lib/zexbox/jira_client.ex index 517e7f9..4e70333 100644 --- a/lib/zexbox/jira_client.ex +++ b/lib/zexbox/jira_client.ex @@ -55,7 +55,7 @@ defmodule Zexbox.JiraClient do issues = Enum.map(issues, &Map.put(&1, "url", "#{base_url}/browse/#{&1["key"]}")) {:ok, issues} - {:error, _} = err -> + {:error, _reason} = err -> err end end @@ -94,7 +94,7 @@ defmodule Zexbox.JiraClient do base_url = config(:jira_base_url, nil) {:ok, Map.put(result, "url", "#{base_url}/browse/#{result["key"]}")} - {:error, _} = err -> + {:error, _reason} = err -> err end end @@ -114,7 +114,7 @@ defmodule Zexbox.JiraClient do with {:ok, data} <- jira_get(client, "/rest/api/3/issue/#{issue_key}/transitions"), transitions = Map.get(data, "transitions", []), {:ok, target} <- find_transition(transitions, status_name), - {:ok, _} <- + {:ok, _resp} <- jira_post(client, "/rest/api/3/issue/#{issue_key}/transitions", %{ "transition" => %{"id" => target["id"]} }) do From 8fac2a3112ea6d793c4e7bcd6e5854520d002b4e Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 14:51:45 +0000 Subject: [PATCH 07/31] feat: Add AdfBuilder for Jira ADF document generation Adds Zexbox.AutoEscalation.AdfBuilder, which builds Atlassian Document Format (ADF) maps for new Jira issue descriptions and occurrence comments. Structure produced: - Telemetry paragraph (Datadog | Tempo | Kibana links, or "(Missing)") - Optional divider + custom description paragraphs - User context and additional context bullet lists - Error Details heading, exception class/message, expandable stack trace Both build_description/4 and build_comment/5 accept keyword opts for the optional custom_description and stacktrace arguments. Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/auto_escalation/adf_builder.ex | 189 +++++++++++++++ .../auto_escalation/adf_builder_test.exs | 220 ++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 lib/zexbox/auto_escalation/adf_builder.ex create mode 100644 test/zexbox/auto_escalation/adf_builder_test.exs diff --git a/lib/zexbox/auto_escalation/adf_builder.ex b/lib/zexbox/auto_escalation/adf_builder.ex new file mode 100644 index 0000000..ebe276e --- /dev/null +++ b/lib/zexbox/auto_escalation/adf_builder.ex @@ -0,0 +1,189 @@ +defmodule Zexbox.AutoEscalation.AdfBuilder do + @moduledoc """ + Builds Atlassian Document Format (ADF) maps for Jira issue descriptions and comments. + + Mirrors `Opsbox::AutoEscalation::AdfBuilder`. Produces the same ADF structure so + headings, links, bullet lists, and the stack-trace expand render correctly in Jira. + + Unlike the Ruby version, `stacktrace` must be passed explicitly (as `__STACKTRACE__` + from a `rescue` block) because Elixir exceptions do not carry their own backtrace. + """ + + alias Zexbox.OpenTelemetry + + @doc """ + Builds an ADF description map for a new Jira issue. + + Structure: + 1. Telemetry links (Datadog | Tempo | Kibana) + 2. Divider (if `custom_description` present) + 3. Custom description paragraphs (split on `\\n\\n`) + 4. User context bullet list (if non-empty) + 5. Additional context bullet list (if non-empty) + 6. H3 "Error Details" + 7. Exception summary + expandable stack trace + """ + @spec build_description( + Exception.t(), + map(), + map(), + keyword() + ) :: map() + def build_description(exception, user_context, additional_context, opts \\ []) do + custom_description = Keyword.get(opts, :custom_description) + stacktrace = Keyword.get(opts, :stacktrace) + + content = + [telemetry_paragraph()] + |> add_if(has_content?(custom_description), divider()) + |> Kernel.++(custom_description_blocks(custom_description)) + |> Kernel.++(context_blocks(user_context, additional_context)) + |> Kernel.++([heading(3, "Error Details")]) + |> Kernel.++(error_details_blocks(exception, stacktrace)) + + doc(content) + end + + @doc """ + Builds an ADF comment map for an additional occurrence on an existing Jira issue. + + Structure: + 1. H2 "Additional Occurrence (action)" + 2. Telemetry links + 3. Divider + custom description (if present) + 4. Context bullet lists (if non-empty) + 5. H3 "Error Details" + 6. Exception summary + expandable stack trace + """ + @spec build_comment( + Exception.t(), + String.t(), + map(), + map(), + keyword() + ) :: map() + def build_comment(exception, action, user_context, additional_context, opts \\ []) do + custom_description = Keyword.get(opts, :custom_description) + stacktrace = Keyword.get(opts, :stacktrace) + + content = + [heading(2, "Additional Occurrence (#{action})"), telemetry_paragraph()] + |> add_if(has_content?(custom_description), divider()) + |> Kernel.++(custom_description_blocks(custom_description)) + |> Kernel.++(context_blocks(user_context, additional_context)) + |> Kernel.++([heading(3, "Error Details")]) + |> Kernel.++(error_details_blocks(exception, stacktrace)) + + doc(content) + end + + # --- Private --- + + defp doc(content), do: %{version: 1, type: "doc", content: content} + + defp telemetry_paragraph do + dd_url = OpenTelemetry.datadog_session_url() + trace_url = OpenTelemetry.generate_trace_url() + kibana_url = OpenTelemetry.kibana_log_url() + + inline = + link_or_plain("Datadog Session Recording", dd_url) ++ + [text(" | ")] ++ + link_or_plain("Tempo Trace View", trace_url) ++ + [text(" | ")] ++ + link_or_plain("Kibana Logs", kibana_url) + + %{type: "paragraph", content: inline} + end + + defp link_or_plain(label, url) when is_binary(url) and url != "" do + [%{type: "text", text: label, marks: [%{type: "link", attrs: %{href: url}}]}] + end + + defp link_or_plain(label, _url) do + [%{type: "text", text: "#{label} (Missing)"}] + end + + defp text(str), do: %{type: "text", text: str} + defp bold(str), do: %{type: "text", text: to_string(str), marks: [%{type: "strong"}]} + defp divider, do: %{type: "rule"} + + defp heading(level, text_content), + do: %{type: "heading", attrs: %{level: level}, content: [text(text_content)]} + + defp code_block(content), + do: %{type: "codeBlock", content: [%{type: "text", text: to_string(content)}]} + + defp expand(title, content_blocks), + do: %{type: "expand", attrs: %{title: title}, content: content_blocks} + + defp custom_description_blocks(nil), do: [] + defp custom_description_blocks(""), do: [] + + defp custom_description_blocks(custom_description) do + custom_description + |> String.trim() + |> String.split(~r/\n\n+/) + |> Enum.map(fn paragraph -> + %{type: "paragraph", content: [text(String.trim(paragraph))]} + end) + end + + defp error_details_blocks(exception, stacktrace) do + error_class = inspect(exception.__struct__) + message = Exception.message(exception) + summary = "#{error_class}: #{message}" + + backtrace = + case stacktrace do + nil -> "No stack trace available" + [] -> "No stack trace available" + st -> Exception.format_stacktrace(st) + end + + [ + %{type: "paragraph", content: [text(summary)]}, + expand("Stack trace", [code_block(backtrace)]) + ] + end + + defp context_blocks(user_context, additional_context) do + [] + |> maybe_add_context("User Context", user_context) + |> maybe_add_context("Additional Context", additional_context) + end + + defp maybe_add_context(blocks, _label, ctx) when is_map(ctx) and map_size(ctx) == 0, do: blocks + defp maybe_add_context(blocks, _label, ctx) when not is_map(ctx), do: blocks + + defp maybe_add_context(blocks, label, ctx) do + blocks ++ + [%{type: "paragraph", content: [bold(label)]}] ++ + [key_value_bullet_list(ctx)] + end + + defp key_value_bullet_list(hash) do + items = + Enum.map(hash, fn {key, value} -> + %{ + type: "listItem", + content: [ + %{ + type: "paragraph", + content: [bold(key), text(": "), text(to_string(value))] + } + ] + } + end) + + %{type: "bulletList", content: items} + end + + defp add_if(list, true, item), do: list ++ [item] + defp add_if(list, false, _item), do: list + + defp has_content?(nil), do: false + defp has_content?(""), do: false + defp has_content?(str) when is_binary(str), do: String.trim(str) != "" + defp has_content?(_), do: false +end diff --git a/test/zexbox/auto_escalation/adf_builder_test.exs b/test/zexbox/auto_escalation/adf_builder_test.exs new file mode 100644 index 0000000..1566c5e --- /dev/null +++ b/test/zexbox/auto_escalation/adf_builder_test.exs @@ -0,0 +1,220 @@ +defmodule Zexbox.AutoEscalation.AdfBuilderTest do + use ExUnit.Case + import Mock + alias Zexbox.AutoEscalation.AdfBuilder + + defp with_telemetry_urls(dd, trace, kibana, fun) do + with_mocks([ + {Zexbox.OpenTelemetry, [], + [ + datadog_session_url: fn -> dd end, + generate_trace_url: fn -> trace end, + kibana_log_url: fn -> kibana end + ]} + ]) do + fun.() + end + end + + defp with_all_urls(fun), + do: + with_telemetry_urls( + "https://dd.example.com/session", + "https://grafana.example.com/trace", + "https://kibana.example.com/logs", + fun + ) + + defp with_no_urls(fun), do: with_telemetry_urls(nil, nil, nil, fun) + + defp runtime_error(msg \\ "Something broke"), do: %RuntimeError{message: msg} + + describe "build_description/5" do + test "returns an ADF doc map with correct version and type" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{}) + assert result.version == 1 + assert result.type == "doc" + assert is_list(result.content) + end) + end + + test "first block is a telemetry paragraph with all three links" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{}) + [telemetry | _] = result.content + assert telemetry.type == "paragraph" + json = Jason.encode!(telemetry) + assert json =~ "Datadog Session Recording" + assert json =~ "https://dd.example.com/session" + assert json =~ "Tempo Trace View" + assert json =~ "https://grafana.example.com/trace" + assert json =~ "Kibana Logs" + assert json =~ "https://kibana.example.com/logs" + end) + end + + test "shows '(Missing)' for unavailable telemetry URLs" do + with_no_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{}) + [telemetry | _] = result.content + json = Jason.encode!(telemetry) + assert json =~ "Datadog Session Recording (Missing)" + assert json =~ "Tempo Trace View (Missing)" + assert json =~ "Kibana Logs (Missing)" + end) + end + + test "includes Error Details heading, exception class, message, and stack trace expand" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error("boom"), %{}, %{}) + json = Jason.encode!(result) + assert json =~ "Error Details" + assert json =~ "RuntimeError" + assert json =~ "boom" + assert json =~ "Stack trace" + assert json =~ "No stack trace available" + end) + end + + test "formats provided stacktrace in the expand block" do + with_all_urls(fn -> + stacktrace = [{MyModule, :my_fn, 2, [file: ~c"lib/my_module.ex", line: 42]}] + result = AdfBuilder.build_description(runtime_error(), %{}, %{}, stacktrace: stacktrace) + json = Jason.encode!(result) + assert json =~ "Stack trace" + assert json =~ "my_module.ex" + end) + end + + test "includes custom_description paragraphs above Error Details" do + with_all_urls(fn -> + result = + AdfBuilder.build_description(runtime_error(), %{}, %{}, + custom_description: "This happened during sync." + ) + + json = Jason.encode!(result) + assert json =~ "This happened during sync." + assert json =~ "Error Details" + + {cd_pos, _} = :binary.match(json, "This happened during sync.") + {ed_pos, _} = :binary.match(json, "Error Details") + assert cd_pos < ed_pos + end) + end + + test "splits custom_description on double newlines into multiple paragraphs" do + with_all_urls(fn -> + result = + AdfBuilder.build_description(runtime_error(), %{}, %{}, + custom_description: "First.\n\nSecond." + ) + json = Jason.encode!(result) + assert json =~ "First." + assert json =~ "Second." + end) + end + + test "adds a divider before custom_description when present" do + with_all_urls(fn -> + result = + AdfBuilder.build_description(runtime_error(), %{}, %{}, custom_description: "Some context.") + json = Jason.encode!(result) + assert json =~ ~s("type":"rule") + end) + end + + test "does not add a divider when custom_description is nil" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{}) + json = Jason.encode!(result) + refute json =~ ~s("type":"rule") + end) + end + + test "includes user_context as a bold key-value bullet list" do + with_all_urls(fn -> + result = + AdfBuilder.build_description(runtime_error(), %{email: "u@example.com"}, %{}) + + json = Jason.encode!(result) + assert json =~ "User Context" + assert json =~ "email" + assert json =~ "u@example.com" + end) + end + + test "includes additional_context as a bold key-value bullet list" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{basket_id: 42}) + json = Jason.encode!(result) + assert json =~ "Additional Context" + assert json =~ "basket_id" + assert json =~ "42" + end) + end + + test "omits User Context section when empty" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{}) + json = Jason.encode!(result) + refute json =~ "User Context" + end) + end + + test "omits Additional Context section when empty" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{}) + json = Jason.encode!(result) + refute json =~ "Additional Context" + end) + end + end + + describe "build_comment/6" do + test "starts with an Additional Occurrence heading including action name" do + with_all_urls(fn -> + result = AdfBuilder.build_comment(runtime_error(), "checkout", %{}, %{}) + json = Jason.encode!(result) + assert json =~ "Additional Occurrence (checkout)" + end) + end + + test "includes telemetry paragraph" do + with_all_urls(fn -> + result = AdfBuilder.build_comment(runtime_error(), "pay", %{}, %{}) + json = Jason.encode!(result) + assert json =~ "Datadog Session Recording" + end) + end + + test "includes exception details and stack trace" do + with_all_urls(fn -> + result = AdfBuilder.build_comment(runtime_error("boom"), "pay", %{}, %{}) + json = Jason.encode!(result) + assert json =~ "RuntimeError" + assert json =~ "boom" + assert json =~ "Stack trace" + end) + end + + test "includes context bullet lists when provided" do + with_all_urls(fn -> + result = + AdfBuilder.build_comment( + runtime_error(), + "checkout", + %{email: "u@example.com"}, + %{basket_id: 123} + ) + + json = Jason.encode!(result) + assert json =~ "User Context" + assert json =~ "u@example.com" + assert json =~ "Additional Context" + assert json =~ "123" + end) + end + end +end From 6d88a9e19b6d1bc04facc0f2cd931dc860ed36c2 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 14:58:43 +0000 Subject: [PATCH 08/31] style: Run mix format Co-Authored-By: Claude Sonnet 4.6 --- test/zexbox/auto_escalation/adf_builder_test.exs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/zexbox/auto_escalation/adf_builder_test.exs b/test/zexbox/auto_escalation/adf_builder_test.exs index 1566c5e..d4c749d 100644 --- a/test/zexbox/auto_escalation/adf_builder_test.exs +++ b/test/zexbox/auto_escalation/adf_builder_test.exs @@ -110,6 +110,7 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do AdfBuilder.build_description(runtime_error(), %{}, %{}, custom_description: "First.\n\nSecond." ) + json = Jason.encode!(result) assert json =~ "First." assert json =~ "Second." @@ -119,7 +120,10 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "adds a divider before custom_description when present" do with_all_urls(fn -> result = - AdfBuilder.build_description(runtime_error(), %{}, %{}, custom_description: "Some context.") + AdfBuilder.build_description(runtime_error(), %{}, %{}, + custom_description: "Some context." + ) + json = Jason.encode!(result) assert json =~ ~s("type":"rule") end) From 22cf890ba9a6ef1ef24f4f8d71b9d07ea571e2c4 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 17:07:16 +0000 Subject: [PATCH 09/31] feat: Remove Datadog session link from telemetry paragraph Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/auto_escalation/adf_builder.ex | 5 +---- test/zexbox/auto_escalation/adf_builder_test.exs | 13 ++++--------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/lib/zexbox/auto_escalation/adf_builder.ex b/lib/zexbox/auto_escalation/adf_builder.ex index ebe276e..f99f0e2 100644 --- a/lib/zexbox/auto_escalation/adf_builder.ex +++ b/lib/zexbox/auto_escalation/adf_builder.ex @@ -82,14 +82,11 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do defp doc(content), do: %{version: 1, type: "doc", content: content} defp telemetry_paragraph do - dd_url = OpenTelemetry.datadog_session_url() trace_url = OpenTelemetry.generate_trace_url() kibana_url = OpenTelemetry.kibana_log_url() inline = - link_or_plain("Datadog Session Recording", dd_url) ++ - [text(" | ")] ++ - link_or_plain("Tempo Trace View", trace_url) ++ + link_or_plain("Tempo Trace View", trace_url) ++ [text(" | ")] ++ link_or_plain("Kibana Logs", kibana_url) diff --git a/test/zexbox/auto_escalation/adf_builder_test.exs b/test/zexbox/auto_escalation/adf_builder_test.exs index d4c749d..33a85cd 100644 --- a/test/zexbox/auto_escalation/adf_builder_test.exs +++ b/test/zexbox/auto_escalation/adf_builder_test.exs @@ -3,11 +3,10 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do import Mock alias Zexbox.AutoEscalation.AdfBuilder - defp with_telemetry_urls(dd, trace, kibana, fun) do + defp with_telemetry_urls(trace, kibana, fun) do with_mocks([ {Zexbox.OpenTelemetry, [], [ - datadog_session_url: fn -> dd end, generate_trace_url: fn -> trace end, kibana_log_url: fn -> kibana end ]} @@ -19,13 +18,12 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do defp with_all_urls(fun), do: with_telemetry_urls( - "https://dd.example.com/session", "https://grafana.example.com/trace", "https://kibana.example.com/logs", fun ) - defp with_no_urls(fun), do: with_telemetry_urls(nil, nil, nil, fun) + defp with_no_urls(fun), do: with_telemetry_urls(nil, nil, fun) defp runtime_error(msg \\ "Something broke"), do: %RuntimeError{message: msg} @@ -39,14 +37,12 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do end) end - test "first block is a telemetry paragraph with all three links" do + test "first block is a telemetry paragraph with Tempo and Kibana links" do with_all_urls(fn -> result = AdfBuilder.build_description(runtime_error(), %{}, %{}) [telemetry | _] = result.content assert telemetry.type == "paragraph" json = Jason.encode!(telemetry) - assert json =~ "Datadog Session Recording" - assert json =~ "https://dd.example.com/session" assert json =~ "Tempo Trace View" assert json =~ "https://grafana.example.com/trace" assert json =~ "Kibana Logs" @@ -59,7 +55,6 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do result = AdfBuilder.build_description(runtime_error(), %{}, %{}) [telemetry | _] = result.content json = Jason.encode!(telemetry) - assert json =~ "Datadog Session Recording (Missing)" assert json =~ "Tempo Trace View (Missing)" assert json =~ "Kibana Logs (Missing)" end) @@ -189,7 +184,7 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do with_all_urls(fn -> result = AdfBuilder.build_comment(runtime_error(), "pay", %{}, %{}) json = Jason.encode!(result) - assert json =~ "Datadog Session Recording" + assert json =~ "Tempo Trace View" end) end From 4ec7726e01783967b885361c5612608d6e993e5b Mon Sep 17 00:00:00 2001 From: Jez-A Date: Mon, 9 Mar 2026 10:17:08 +0000 Subject: [PATCH 10/31] fix: Name bare _ wildcards to satisfy Credo consistency check Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/auto_escalation/adf_builder.ex | 2 +- test/zexbox/auto_escalation/adf_builder_test.exs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/zexbox/auto_escalation/adf_builder.ex b/lib/zexbox/auto_escalation/adf_builder.ex index f99f0e2..b05231d 100644 --- a/lib/zexbox/auto_escalation/adf_builder.ex +++ b/lib/zexbox/auto_escalation/adf_builder.ex @@ -182,5 +182,5 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do defp has_content?(nil), do: false defp has_content?(""), do: false defp has_content?(str) when is_binary(str), do: String.trim(str) != "" - defp has_content?(_), do: false + defp has_content?(_other), do: false end diff --git a/test/zexbox/auto_escalation/adf_builder_test.exs b/test/zexbox/auto_escalation/adf_builder_test.exs index 33a85cd..e74d7e4 100644 --- a/test/zexbox/auto_escalation/adf_builder_test.exs +++ b/test/zexbox/auto_escalation/adf_builder_test.exs @@ -40,7 +40,7 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "first block is a telemetry paragraph with Tempo and Kibana links" do with_all_urls(fn -> result = AdfBuilder.build_description(runtime_error(), %{}, %{}) - [telemetry | _] = result.content + [telemetry | _rest] = result.content assert telemetry.type == "paragraph" json = Jason.encode!(telemetry) assert json =~ "Tempo Trace View" @@ -53,7 +53,7 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "shows '(Missing)' for unavailable telemetry URLs" do with_no_urls(fn -> result = AdfBuilder.build_description(runtime_error(), %{}, %{}) - [telemetry | _] = result.content + [telemetry | _rest] = result.content json = Jason.encode!(telemetry) assert json =~ "Tempo Trace View (Missing)" assert json =~ "Kibana Logs (Missing)" @@ -93,8 +93,8 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do assert json =~ "This happened during sync." assert json =~ "Error Details" - {cd_pos, _} = :binary.match(json, "This happened during sync.") - {ed_pos, _} = :binary.match(json, "Error Details") + {cd_pos, _len} = :binary.match(json, "This happened during sync.") + {ed_pos, _len} = :binary.match(json, "Error Details") assert cd_pos < ed_pos end) end From 4bdc42c2c5f084e3a1b1dd34df54ade5ba26846a Mon Sep 17 00:00:00 2001 From: Jez-A Date: Mon, 9 Mar 2026 14:28:19 +0000 Subject: [PATCH 11/31] feat: Remove Jaeger fallback from generate_trace_url Jaeger is not available locally. generate_trace_url/0 now returns nil for non-production/sandbox environments instead of a localhost Jaeger URL. Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/open_telemetry.ex | 15 +++++---------- test/zexbox/open_telemetry_test.exs | 14 ++------------ 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/lib/zexbox/open_telemetry.ex b/lib/zexbox/open_telemetry.ex index 5fd0453..ca15d61 100644 --- a/lib/zexbox/open_telemetry.ex +++ b/lib/zexbox/open_telemetry.ex @@ -34,20 +34,15 @@ defmodule Zexbox.OpenTelemetry do end @doc """ - Returns a Grafana Tempo trace URL for the current span, or `nil` if no active span. - Returns a local Jaeger URL when `app_env` is `:dev` / `:development`. + Returns a Grafana Tempo trace URL for the current span, or `nil` if no active span + or if the environment is not `:production` or `:sandbox`. """ @spec generate_trace_url() :: String.t() | nil def generate_trace_url do - if valid_active_span?() do + if valid_active_span?() && app_env() in [:production, :sandbox] do trace_id = hex_trace_id() - - if app_env() in [:dev, :development] do - "http://localhost:16686/trace/#{trace_id}" - else - pane_json = build_pane_json(trace_id) - "https://zappi.grafana.net/explore?schemaVersion=1&panes=#{Jason.encode!(pane_json)}" - end + pane_json = build_pane_json(trace_id) + "https://zappi.grafana.net/explore?schemaVersion=1&panes=#{Jason.encode!(pane_json)}" end rescue _e -> nil diff --git a/test/zexbox/open_telemetry_test.exs b/test/zexbox/open_telemetry_test.exs index 8887635..1c51222 100644 --- a/test/zexbox/open_telemetry_test.exs +++ b/test/zexbox/open_telemetry_test.exs @@ -87,21 +87,11 @@ defmodule Zexbox.OpenTelemetryTest do end) end - test "returns a local Jaeger URL in :dev environment" do + test "returns nil in non-production environments" do Application.put_env(:zexbox, :app_env, :dev) with_active_span(fn -> - url = OpenTelemetry.generate_trace_url() - assert url == "http://localhost:16686/trace/#{@mock_trace_id_hex}" - end) - end - - test "returns a local Jaeger URL in :development environment" do - Application.put_env(:zexbox, :app_env, :development) - - with_active_span(fn -> - url = OpenTelemetry.generate_trace_url() - assert url == "http://localhost:16686/trace/#{@mock_trace_id_hex}" + assert OpenTelemetry.generate_trace_url() == nil end) end From 61b7154152c5c936ccf8b9d8dd91168454a1eac1 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 14:47:56 +0000 Subject: [PATCH 12/31] feat: Add JiraClient and OpenTelemetry URL helpers Adds two new utility modules required by the AutoEscalation feature: - Zexbox.JiraClient: Req-based client for the Jira Cloud REST API v3. Supports search, create, transition, and add_comment operations. Authenticates via JIRA_USER_EMAIL_ADDRESS / JIRA_API_TOKEN env vars or :jira_email / :jira_api_token application config. - Zexbox.OpenTelemetry: Reads the current process OTEL context (baggage and active span) to produce Datadog session, Grafana Tempo trace, and Kibana Discover URLs. Returns nil gracefully when OTEL is unconfigured. Adds :req, :jason, and :opentelemetry_api dependencies to mix.exs. Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/jira_client.ex | 208 +++++++++++++++++++++++++++++++ mix.exs | 3 +- test/zexbox/jira_client_test.exs | 196 +++++++++++++++++++++++++++++ 3 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 lib/zexbox/jira_client.ex create mode 100644 test/zexbox/jira_client_test.exs diff --git a/lib/zexbox/jira_client.ex b/lib/zexbox/jira_client.ex new file mode 100644 index 0000000..5f1d390 --- /dev/null +++ b/lib/zexbox/jira_client.ex @@ -0,0 +1,208 @@ +defmodule Zexbox.JiraClient do + @moduledoc """ + HTTP client for the Jira Cloud REST API v3. + + Mirrors `Opsbox::JiraClient`. Authenticates with Basic auth using + `JIRA_USER_EMAIL_ADDRESS` and `JIRA_API_TOKEN` environment variables + (or `:jira_email` / `:jira_api_token` application config). + + ## Configuration + + ```elixir + config :zexbox, + jira_base_url: "https://your-org.atlassian.net", + jira_email: System.get_env("JIRA_USER_EMAIL_ADDRESS"), + jira_api_token: System.get_env("JIRA_API_TOKEN") + ``` + + All public functions return `{:ok, result}` or `{:error, reason}`. + """ + + @bug_fingerprint_field %{id: "customfield_13442", name: "Bug Fingerprint[Short text]"} + @zigl_team_field %{id: "customfield_10101", name: "ZIGL Team[Dropdown]"} + + @doc "Returns the bug fingerprint custom field metadata." + @spec bug_fingerprint_field() :: %{id: String.t(), name: String.t()} + def bug_fingerprint_field, do: @bug_fingerprint_field + + @doc "Returns the ZIGL team custom field metadata." + @spec zigl_team_field() :: %{id: String.t(), name: String.t()} + def zigl_team_field, do: @zigl_team_field + + @doc """ + Search for the latest issues matching a JQL query (max 50 results). + + Options: + - `:jql` (required) – JQL query string. + - `:project_key` (optional) – prepends `project = KEY AND` to the JQL. + + Returns `{:ok, [issue_map]}` where each map includes a `"url"` browse key, + or `{:error, reason}` on failure. + """ + @spec search_latest_issues(keyword()) :: {:ok, [map()]} | {:error, term()} + def search_latest_issues(opts) do + jql = Keyword.fetch!(opts, :jql) + project_key = Keyword.get(opts, :project_key) + + query = if project_key, do: "project = #{project_key} AND #{jql}", else: jql + + client = build_client() + + case jira_get(client, "/rest/api/3/search/jql", + jql: query, + maxResults: 50, + fields: "key,id,self,status,summary" + ) do + {:ok, body} -> + base_url = config(:jira_base_url, nil) + issues = Map.get(body, "issues", []) + issues = Enum.map(issues, &Map.put(&1, "url", "#{base_url}/browse/#{&1["key"]}")) + {:ok, issues} + + {:error, _} = err -> + err + end + end + + @doc """ + Create a new Jira issue. + + Options: + - `:project_key` – Jira project key (e.g. `"SS"`). + - `:summary` – issue summary string. + - `:description` – ADF map (already built; not converted). + - `:issuetype` – issue type name (e.g. `"Bug"`). + - `:priority` – priority name (e.g. `"High"`). + - `:custom_fields` – map of custom field ID → value (string keys). + + Returns `{:ok, issue_map}` with a `"url"` browse key added, or `{:error, reason}`. + """ + @spec create_issue(keyword()) :: {:ok, map()} | {:error, term()} + def create_issue(opts) do + project_key = Keyword.fetch!(opts, :project_key) + summary = Keyword.fetch!(opts, :summary) + description = Keyword.fetch!(opts, :description) + issuetype = Keyword.fetch!(opts, :issuetype) + priority = Keyword.fetch!(opts, :priority) + custom_fields = Keyword.get(opts, :custom_fields, %{}) + + fields = + Map.merge( + %{ + "project" => %{"key" => project_key}, + "summary" => summary, + "description" => description, + "issuetype" => %{"name" => issuetype}, + "priority" => %{"name" => priority} + }, + custom_fields + ) + + client = build_client() + + case jira_post(client, "/rest/api/3/issue", %{"fields" => fields}) do + {:ok, result} -> + base_url = config(:jira_base_url, nil) + {:ok, Map.put(result, "url", "#{base_url}/browse/#{result["key"]}")} + + {:error, _} = err -> + err + end + end + + @doc """ + Transition a Jira issue to a new status by name (case-insensitive match). + + Options: + - `:issue_key` – issue key (e.g. `"SS-42"`). + - `:status_name` – target status name (e.g. `"To do"`). + + Returns `{:ok, %{success: true, status: name}}` or `{:error, reason}`. + """ + @spec transition_issue(keyword()) :: {:ok, map()} | {:error, term()} + def transition_issue(opts) do + issue_key = Keyword.fetch!(opts, :issue_key) + status_name = Keyword.fetch!(opts, :status_name) + + client = build_client() + + with {:ok, data} <- jira_get(client, "/rest/api/3/issue/#{issue_key}/transitions"), + transitions = Map.get(data, "transitions", []), + {:ok, target} <- find_transition(transitions, status_name), + {:ok, _} <- + jira_post(client, "/rest/api/3/issue/#{issue_key}/transitions", %{ + "transition" => %{"id" => target["id"]} + }) do + {:ok, %{success: true, status: get_in(target, ["to", "name"])}} + end + end + + @doc """ + Add a comment to an existing Jira issue. + + Options: + - `:issue_key` – issue key (e.g. `"SS-42"`). + - `:comment` – ADF map for the comment body (already built; not converted). + + Returns `{:ok, comment_map}` or `{:error, reason}`. + """ + @spec add_comment(keyword()) :: {:ok, map()} | {:error, term()} + def add_comment(opts) do + issue_key = Keyword.fetch!(opts, :issue_key) + comment = Keyword.fetch!(opts, :comment) + + client = build_client() + jira_post(client, "/rest/api/3/issue/#{issue_key}/comment", %{"body" => comment}) + end + + # --- Private helpers --- + + defp find_transition(transitions, status_name) do + case Enum.find(transitions, fn t -> + to_name = get_in(t, ["to", "name"]) || "" + String.downcase(to_name) == String.downcase(status_name) + end) do + nil -> {:error, "Cannot transition to '#{status_name}'"} + target -> {:ok, target} + end + end + + defp jira_get(client, path, params \\ []) do + case Req.get(client, url: path, params: params) do + {:ok, %{status: status, body: body}} when status in 200..299 -> + {:ok, body || %{}} + + {:ok, %{status: status, body: body}} -> + {:error, "HTTP #{status}: #{inspect(body)}"} + + {:error, reason} -> + {:error, inspect(reason)} + end + end + + defp jira_post(client, path, body) do + case Req.post(client, url: path, json: body) do + {:ok, %{status: status, body: resp_body}} when status in 200..299 -> + {:ok, resp_body || %{}} + + {:ok, %{status: status, body: resp_body}} -> + {:error, "HTTP #{status}: #{inspect(resp_body)}"} + + {:error, reason} -> + {:error, inspect(reason)} + end + end + + defp build_client do + base_url = config(:jira_base_url, nil) + email = config(:jira_email, System.get_env("JIRA_USER_EMAIL_ADDRESS", "")) + token = config(:jira_api_token, System.get_env("JIRA_API_TOKEN", "")) + + Req.new( + base_url: base_url, + auth: {:basic, "#{email}:#{token}"} + ) + end + + defp config(key, default), do: Application.get_env(:zexbox, key, default) +end diff --git a/mix.exs b/mix.exs index 6702ea0..417f2c2 100644 --- a/mix.exs +++ b/mix.exs @@ -38,10 +38,11 @@ defmodule Zexbox.MixProject do {:ex_doc, "~> 0.35.1", only: :dev, runtime: false}, {:instream, "~> 2.2"}, {:jason, "~> 1.4"}, - {:opentelemetry_api, "~> 1.4"}, {:ldclient, "~> 3.8.0", hex: :launchdarkly_server_sdk}, {:mix_audit, "~> 2.0", only: [:dev, :test], runtime: false}, {:mock, "~> 0.3.0", only: :test}, + {:opentelemetry_api, "~> 1.4"}, + {:req, "~> 0.5"}, {:sobelow, "~> 0.8", only: [:dev, :test]}, {:telemetry, "~> 1.3"} ] diff --git a/test/zexbox/jira_client_test.exs b/test/zexbox/jira_client_test.exs new file mode 100644 index 0000000..0ea02fb --- /dev/null +++ b/test/zexbox/jira_client_test.exs @@ -0,0 +1,196 @@ +defmodule Zexbox.JiraClientTest do + use ExUnit.Case + import Mock + alias Zexbox.JiraClient + + @base_url "https://zigroup.atlassian.net" + + setup do + Application.put_env(:zexbox, :jira_base_url, @base_url) + Application.put_env(:zexbox, :jira_email, "test@example.com") + Application.put_env(:zexbox, :jira_api_token, "test-token") + + on_exit(fn -> + Application.delete_env(:zexbox, :jira_base_url) + Application.delete_env(:zexbox, :jira_email) + Application.delete_env(:zexbox, :jira_api_token) + end) + + :ok + end + + describe "bug_fingerprint_field/0" do + test "returns the correct field metadata" do + assert %{id: "customfield_13442", name: "Bug Fingerprint[Short text]"} = + JiraClient.bug_fingerprint_field() + end + end + + describe "zigl_team_field/0" do + test "returns the correct field metadata" do + assert %{id: "customfield_10101", name: "ZIGL Team[Dropdown]"} = + JiraClient.zigl_team_field() + end + end + + describe "search_latest_issues/1" do + test_with_mock "returns issues with url keys added on success", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "issues" => [ + %{"key" => "SS-1", "id" => "10001", "self" => "#{@base_url}/rest/api/3/issue/10001"} + ] + } + }} + end do + assert {:ok, [issue]} = JiraClient.search_latest_issues(jql: "status = Open", project_key: "SS") + assert issue["key"] == "SS-1" + assert issue["url"] == "#{@base_url}/browse/SS-1" + end + + test_with_mock "returns empty list when no issues found", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, %{status: 200, body: %{"issues" => []}}} + end do + assert {:ok, []} = JiraClient.search_latest_issues(jql: "status = Open") + end + + test_with_mock "returns error on non-2xx response", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, %{status: 401, body: %{"message" => "Unauthorized"}}} + end do + assert {:error, reason} = JiraClient.search_latest_issues(jql: "status = Open") + assert reason =~ "HTTP 401" + end + + test_with_mock "returns error on request failure", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:error, %{reason: :econnrefused}} + end do + assert {:error, _reason} = JiraClient.search_latest_issues(jql: "status = Open") + end + end + + describe "create_issue/1" do + test_with_mock "creates issue and adds url to result", Req, + new: fn _opts -> :mock_client end, + post: fn :mock_client, _opts -> + {:ok, + %{ + status: 201, + body: %{ + "key" => "SS-99", + "id" => "10099", + "self" => "#{@base_url}/rest/api/3/issue/10099" + } + }} + end do + assert {:ok, result} = + JiraClient.create_issue( + project_key: "SS", + summary: "checkout: RuntimeError", + description: %{version: 1, type: "doc", content: []}, + issuetype: "Bug", + priority: "High", + custom_fields: %{"customfield_13442" => "checkout::RuntimeError"} + ) + + assert result["key"] == "SS-99" + assert result["url"] == "#{@base_url}/browse/SS-99" + end + + test_with_mock "returns error on non-2xx response", Req, + new: fn _opts -> :mock_client end, + post: fn :mock_client, _opts -> + {:ok, %{status: 400, body: %{"errorMessages" => ["Invalid project"]}}} + end do + assert {:error, reason} = + JiraClient.create_issue( + project_key: "INVALID", + summary: "test", + description: %{}, + issuetype: "Bug", + priority: "High", + custom_fields: %{} + ) + + assert reason =~ "HTTP 400" + end + end + + describe "transition_issue/1" do + test_with_mock "transitions issue to target status", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "transitions" => [ + %{"id" => "11", "to" => %{"name" => "To do"}}, + %{"id" => "21", "to" => %{"name" => "In Progress"}} + ] + } + }} + end, + post: fn :mock_client, opts -> + assert opts[:json] == %{"transition" => %{"id" => "11"}} + {:ok, %{status: 204, body: nil}} + end do + assert {:ok, %{success: true, status: "To do"}} = + JiraClient.transition_issue(issue_key: "SS-1", status_name: "To do") + end + + test_with_mock "returns error when target status not found", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, + %{ + status: 200, + body: %{"transitions" => [%{"id" => "11", "to" => %{"name" => "Done"}}]} + }} + end do + assert {:error, reason} = + JiraClient.transition_issue(issue_key: "SS-1", status_name: "Nonexistent") + + assert reason =~ "Cannot transition to" + end + end + + describe "add_comment/1" do + test_with_mock "adds comment and returns the response", Req, + new: fn _opts -> :mock_client end, + post: fn :mock_client, _opts -> + {:ok, + %{ + status: 201, + body: %{"id" => "30001", "body" => %{}, "author" => %{"displayName" => "Bot"}} + }} + end do + comment = %{version: 1, type: "doc", content: []} + + assert {:ok, result} = + JiraClient.add_comment(issue_key: "SS-42", comment: comment) + + assert result["id"] == "30001" + end + + test_with_mock "returns error on non-2xx response", Req, + new: fn _opts -> :mock_client end, + post: fn :mock_client, _opts -> + {:ok, %{status: 404, body: %{"errorMessages" => ["Issue not found"]}}} + end do + assert {:error, reason} = + JiraClient.add_comment(issue_key: "SS-999", comment: %{}) + + assert reason =~ "HTTP 404" + end + end +end From 51138766c03cc08f68ef42dc31aaae05e4422dba Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 14:59:02 +0000 Subject: [PATCH 13/31] style: Run mix format Co-Authored-By: Claude Sonnet 4.6 --- test/zexbox/jira_client_test.exs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/zexbox/jira_client_test.exs b/test/zexbox/jira_client_test.exs index 0ea02fb..2c6a953 100644 --- a/test/zexbox/jira_client_test.exs +++ b/test/zexbox/jira_client_test.exs @@ -42,12 +42,18 @@ defmodule Zexbox.JiraClientTest do status: 200, body: %{ "issues" => [ - %{"key" => "SS-1", "id" => "10001", "self" => "#{@base_url}/rest/api/3/issue/10001"} + %{ + "key" => "SS-1", + "id" => "10001", + "self" => "#{@base_url}/rest/api/3/issue/10001" + } ] } }} end do - assert {:ok, [issue]} = JiraClient.search_latest_issues(jql: "status = Open", project_key: "SS") + assert {:ok, [issue]} = + JiraClient.search_latest_issues(jql: "status = Open", project_key: "SS") + assert issue["key"] == "SS-1" assert issue["url"] == "#{@base_url}/browse/SS-1" end From d474fe2a7d4ccfd61149eef8c1e28d41bed2c6d4 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 15:25:58 +0000 Subject: [PATCH 14/31] fix: Use POST /search/jql for search and add Accept header to all requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_latest_issues was using GET with query params, which means the response body was not guaranteed to be JSON-decoded by Req (Req only auto-decodes based on the response Content-Type, not the request method). Two changes: - search_latest_issues now uses POST /rest/api/3/search/jql with a JSON body — the preferred Jira v3 approach. This also allows fields to be sent as a proper JSON array rather than a comma-separated string. - build_client adds Accept: application/json to every request so the remaining GET (transitions fetch) also explicitly signals JSON intent and Req's decode_body step has a clear content-type to act on. Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/jira_client.ex | 13 +++++++------ test/zexbox/jira_client_test.exs | 8 ++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/zexbox/jira_client.ex b/lib/zexbox/jira_client.ex index 5f1d390..25597ed 100644 --- a/lib/zexbox/jira_client.ex +++ b/lib/zexbox/jira_client.ex @@ -48,11 +48,11 @@ defmodule Zexbox.JiraClient do client = build_client() - case jira_get(client, "/rest/api/3/search/jql", - jql: query, - maxResults: 50, - fields: "key,id,self,status,summary" - ) do + case jira_post(client, "/rest/api/3/search/jql", %{ + "jql" => query, + "maxResults" => 50, + "fields" => ["key", "id", "self", "status", "summary"] + }) do {:ok, body} -> base_url = config(:jira_base_url, nil) issues = Map.get(body, "issues", []) @@ -200,7 +200,8 @@ defmodule Zexbox.JiraClient do Req.new( base_url: base_url, - auth: {:basic, "#{email}:#{token}"} + auth: {:basic, "#{email}:#{token}"}, + headers: [{"accept", "application/json"}] ) end diff --git a/test/zexbox/jira_client_test.exs b/test/zexbox/jira_client_test.exs index 2c6a953..7894441 100644 --- a/test/zexbox/jira_client_test.exs +++ b/test/zexbox/jira_client_test.exs @@ -36,7 +36,7 @@ defmodule Zexbox.JiraClientTest do describe "search_latest_issues/1" do test_with_mock "returns issues with url keys added on success", Req, new: fn _opts -> :mock_client end, - get: fn :mock_client, _opts -> + post: fn :mock_client, _opts -> {:ok, %{ status: 200, @@ -60,7 +60,7 @@ defmodule Zexbox.JiraClientTest do test_with_mock "returns empty list when no issues found", Req, new: fn _opts -> :mock_client end, - get: fn :mock_client, _opts -> + post: fn :mock_client, _opts -> {:ok, %{status: 200, body: %{"issues" => []}}} end do assert {:ok, []} = JiraClient.search_latest_issues(jql: "status = Open") @@ -68,7 +68,7 @@ defmodule Zexbox.JiraClientTest do test_with_mock "returns error on non-2xx response", Req, new: fn _opts -> :mock_client end, - get: fn :mock_client, _opts -> + post: fn :mock_client, _opts -> {:ok, %{status: 401, body: %{"message" => "Unauthorized"}}} end do assert {:error, reason} = JiraClient.search_latest_issues(jql: "status = Open") @@ -77,7 +77,7 @@ defmodule Zexbox.JiraClientTest do test_with_mock "returns error on request failure", Req, new: fn _opts -> :mock_client end, - get: fn :mock_client, _opts -> + post: fn :mock_client, _opts -> {:error, %{reason: :econnrefused}} end do assert {:error, _reason} = JiraClient.search_latest_issues(jql: "status = Open") From a488bbcab13c4302ed6a2b3ce06307004dc3641d Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 15:35:45 +0000 Subject: [PATCH 15/31] refactor: Convert JiraClient public API to explicit positional arguments Replaces keyword list opts (Keyword.fetch!/get) with typed positional arguments throughout, consistent with the rest of the repo's style: search_latest_issues(jql, project_key \\ nil) create_issue(project_key, summary, description, issuetype, priority, custom_fields \\ %{}) transition_issue(issue_key, status_name) add_comment(issue_key, comment) Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/jira_client.ex | 61 +++++++++++--------------------- test/zexbox/jira_client_test.exs | 54 ++++++++++------------------ 2 files changed, 40 insertions(+), 75 deletions(-) diff --git a/lib/zexbox/jira_client.ex b/lib/zexbox/jira_client.ex index 25597ed..517e7f9 100644 --- a/lib/zexbox/jira_client.ex +++ b/lib/zexbox/jira_client.ex @@ -32,18 +32,14 @@ defmodule Zexbox.JiraClient do @doc """ Search for the latest issues matching a JQL query (max 50 results). - Options: - - `:jql` (required) – JQL query string. - - `:project_key` (optional) – prepends `project = KEY AND` to the JQL. + - `jql` – JQL query string. + - `project_key` – optional; prepends `project = KEY AND` to the JQL. Returns `{:ok, [issue_map]}` where each map includes a `"url"` browse key, or `{:error, reason}` on failure. """ - @spec search_latest_issues(keyword()) :: {:ok, [map()]} | {:error, term()} - def search_latest_issues(opts) do - jql = Keyword.fetch!(opts, :jql) - project_key = Keyword.get(opts, :project_key) - + @spec search_latest_issues(String.t(), String.t() | nil) :: {:ok, [map()]} | {:error, term()} + def search_latest_issues(jql, project_key \\ nil) do query = if project_key, do: "project = #{project_key} AND #{jql}", else: jql client = build_client() @@ -67,25 +63,18 @@ defmodule Zexbox.JiraClient do @doc """ Create a new Jira issue. - Options: - - `:project_key` – Jira project key (e.g. `"SS"`). - - `:summary` – issue summary string. - - `:description` – ADF map (already built; not converted). - - `:issuetype` – issue type name (e.g. `"Bug"`). - - `:priority` – priority name (e.g. `"High"`). - - `:custom_fields` – map of custom field ID → value (string keys). + - `project_key` – Jira project key (e.g. `"SS"`). + - `summary` – issue summary string. + - `description` – ADF map (already built; not converted). + - `issuetype` – issue type name (e.g. `"Bug"`). + - `priority` – priority name (e.g. `"High"`). + - `custom_fields` – optional map of custom field ID → value (string keys). Returns `{:ok, issue_map}` with a `"url"` browse key added, or `{:error, reason}`. """ - @spec create_issue(keyword()) :: {:ok, map()} | {:error, term()} - def create_issue(opts) do - project_key = Keyword.fetch!(opts, :project_key) - summary = Keyword.fetch!(opts, :summary) - description = Keyword.fetch!(opts, :description) - issuetype = Keyword.fetch!(opts, :issuetype) - priority = Keyword.fetch!(opts, :priority) - custom_fields = Keyword.get(opts, :custom_fields, %{}) - + @spec create_issue(String.t(), String.t(), map(), String.t(), String.t(), map()) :: + {:ok, map()} | {:error, term()} + def create_issue(project_key, summary, description, issuetype, priority, custom_fields \\ %{}) do fields = Map.merge( %{ @@ -113,17 +102,13 @@ defmodule Zexbox.JiraClient do @doc """ Transition a Jira issue to a new status by name (case-insensitive match). - Options: - - `:issue_key` – issue key (e.g. `"SS-42"`). - - `:status_name` – target status name (e.g. `"To do"`). + - `issue_key` – issue key (e.g. `"SS-42"`). + - `status_name` – target status name (e.g. `"To do"`). Returns `{:ok, %{success: true, status: name}}` or `{:error, reason}`. """ - @spec transition_issue(keyword()) :: {:ok, map()} | {:error, term()} - def transition_issue(opts) do - issue_key = Keyword.fetch!(opts, :issue_key) - status_name = Keyword.fetch!(opts, :status_name) - + @spec transition_issue(String.t(), String.t()) :: {:ok, map()} | {:error, term()} + def transition_issue(issue_key, status_name) do client = build_client() with {:ok, data} <- jira_get(client, "/rest/api/3/issue/#{issue_key}/transitions"), @@ -140,17 +125,13 @@ defmodule Zexbox.JiraClient do @doc """ Add a comment to an existing Jira issue. - Options: - - `:issue_key` – issue key (e.g. `"SS-42"`). - - `:comment` – ADF map for the comment body (already built; not converted). + - `issue_key` – issue key (e.g. `"SS-42"`). + - `comment` – ADF map for the comment body (already built; not converted). Returns `{:ok, comment_map}` or `{:error, reason}`. """ - @spec add_comment(keyword()) :: {:ok, map()} | {:error, term()} - def add_comment(opts) do - issue_key = Keyword.fetch!(opts, :issue_key) - comment = Keyword.fetch!(opts, :comment) - + @spec add_comment(String.t(), map()) :: {:ok, map()} | {:error, term()} + def add_comment(issue_key, comment) do client = build_client() jira_post(client, "/rest/api/3/issue/#{issue_key}/comment", %{"body" => comment}) end diff --git a/test/zexbox/jira_client_test.exs b/test/zexbox/jira_client_test.exs index 7894441..9bbd970 100644 --- a/test/zexbox/jira_client_test.exs +++ b/test/zexbox/jira_client_test.exs @@ -33,7 +33,7 @@ defmodule Zexbox.JiraClientTest do end end - describe "search_latest_issues/1" do + describe "search_latest_issues/2" do test_with_mock "returns issues with url keys added on success", Req, new: fn _opts -> :mock_client end, post: fn :mock_client, _opts -> @@ -51,9 +51,7 @@ defmodule Zexbox.JiraClientTest do } }} end do - assert {:ok, [issue]} = - JiraClient.search_latest_issues(jql: "status = Open", project_key: "SS") - + assert {:ok, [issue]} = JiraClient.search_latest_issues("status = Open", "SS") assert issue["key"] == "SS-1" assert issue["url"] == "#{@base_url}/browse/SS-1" end @@ -63,7 +61,7 @@ defmodule Zexbox.JiraClientTest do post: fn :mock_client, _opts -> {:ok, %{status: 200, body: %{"issues" => []}}} end do - assert {:ok, []} = JiraClient.search_latest_issues(jql: "status = Open") + assert {:ok, []} = JiraClient.search_latest_issues("status = Open") end test_with_mock "returns error on non-2xx response", Req, @@ -71,7 +69,7 @@ defmodule Zexbox.JiraClientTest do post: fn :mock_client, _opts -> {:ok, %{status: 401, body: %{"message" => "Unauthorized"}}} end do - assert {:error, reason} = JiraClient.search_latest_issues(jql: "status = Open") + assert {:error, reason} = JiraClient.search_latest_issues("status = Open") assert reason =~ "HTTP 401" end @@ -80,11 +78,11 @@ defmodule Zexbox.JiraClientTest do post: fn :mock_client, _opts -> {:error, %{reason: :econnrefused}} end do - assert {:error, _reason} = JiraClient.search_latest_issues(jql: "status = Open") + assert {:error, _reason} = JiraClient.search_latest_issues("status = Open") end end - describe "create_issue/1" do + describe "create_issue/6" do test_with_mock "creates issue and adds url to result", Req, new: fn _opts -> :mock_client end, post: fn :mock_client, _opts -> @@ -100,12 +98,12 @@ defmodule Zexbox.JiraClientTest do end do assert {:ok, result} = JiraClient.create_issue( - project_key: "SS", - summary: "checkout: RuntimeError", - description: %{version: 1, type: "doc", content: []}, - issuetype: "Bug", - priority: "High", - custom_fields: %{"customfield_13442" => "checkout::RuntimeError"} + "SS", + "checkout: RuntimeError", + %{version: 1, type: "doc", content: []}, + "Bug", + "High", + %{"customfield_13442" => "checkout::RuntimeError"} ) assert result["key"] == "SS-99" @@ -118,20 +116,13 @@ defmodule Zexbox.JiraClientTest do {:ok, %{status: 400, body: %{"errorMessages" => ["Invalid project"]}}} end do assert {:error, reason} = - JiraClient.create_issue( - project_key: "INVALID", - summary: "test", - description: %{}, - issuetype: "Bug", - priority: "High", - custom_fields: %{} - ) + JiraClient.create_issue("INVALID", "test", %{}, "Bug", "High") assert reason =~ "HTTP 400" end end - describe "transition_issue/1" do + describe "transition_issue/2" do test_with_mock "transitions issue to target status", Req, new: fn _opts -> :mock_client end, get: fn :mock_client, _opts -> @@ -151,7 +142,7 @@ defmodule Zexbox.JiraClientTest do {:ok, %{status: 204, body: nil}} end do assert {:ok, %{success: true, status: "To do"}} = - JiraClient.transition_issue(issue_key: "SS-1", status_name: "To do") + JiraClient.transition_issue("SS-1", "To do") end test_with_mock "returns error when target status not found", Req, @@ -163,14 +154,12 @@ defmodule Zexbox.JiraClientTest do body: %{"transitions" => [%{"id" => "11", "to" => %{"name" => "Done"}}]} }} end do - assert {:error, reason} = - JiraClient.transition_issue(issue_key: "SS-1", status_name: "Nonexistent") - + assert {:error, reason} = JiraClient.transition_issue("SS-1", "Nonexistent") assert reason =~ "Cannot transition to" end end - describe "add_comment/1" do + describe "add_comment/2" do test_with_mock "adds comment and returns the response", Req, new: fn _opts -> :mock_client end, post: fn :mock_client, _opts -> @@ -181,10 +170,7 @@ defmodule Zexbox.JiraClientTest do }} end do comment = %{version: 1, type: "doc", content: []} - - assert {:ok, result} = - JiraClient.add_comment(issue_key: "SS-42", comment: comment) - + assert {:ok, result} = JiraClient.add_comment("SS-42", comment) assert result["id"] == "30001" end @@ -193,9 +179,7 @@ defmodule Zexbox.JiraClientTest do post: fn :mock_client, _opts -> {:ok, %{status: 404, body: %{"errorMessages" => ["Issue not found"]}}} end do - assert {:error, reason} = - JiraClient.add_comment(issue_key: "SS-999", comment: %{}) - + assert {:error, reason} = JiraClient.add_comment("SS-999", %{}) assert reason =~ "HTTP 404" end end From 0fe9c1273d0584446794aad4c022ba9e8c48eae2 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 16:03:21 +0000 Subject: [PATCH 16/31] fix: Name bare _ wildcards to satisfy Credo consistency check Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/jira_client.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/zexbox/jira_client.ex b/lib/zexbox/jira_client.ex index 517e7f9..4e70333 100644 --- a/lib/zexbox/jira_client.ex +++ b/lib/zexbox/jira_client.ex @@ -55,7 +55,7 @@ defmodule Zexbox.JiraClient do issues = Enum.map(issues, &Map.put(&1, "url", "#{base_url}/browse/#{&1["key"]}")) {:ok, issues} - {:error, _} = err -> + {:error, _reason} = err -> err end end @@ -94,7 +94,7 @@ defmodule Zexbox.JiraClient do base_url = config(:jira_base_url, nil) {:ok, Map.put(result, "url", "#{base_url}/browse/#{result["key"]}")} - {:error, _} = err -> + {:error, _reason} = err -> err end end @@ -114,7 +114,7 @@ defmodule Zexbox.JiraClient do with {:ok, data} <- jira_get(client, "/rest/api/3/issue/#{issue_key}/transitions"), transitions = Map.get(data, "transitions", []), {:ok, target} <- find_transition(transitions, status_name), - {:ok, _} <- + {:ok, _resp} <- jira_post(client, "/rest/api/3/issue/#{issue_key}/transitions", %{ "transition" => %{"id" => target["id"]} }) do From 34f4acb3adb64448a50643d7d71aa9faca5d5a77 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Mon, 9 Mar 2026 11:11:18 +0000 Subject: [PATCH 17/31] Cleanup after moving the Telemetry to another PR --- lib/zexbox/open_telemetry.ex | 131 -------------------- test/zexbox/open_telemetry_test.exs | 183 ---------------------------- 2 files changed, 314 deletions(-) delete mode 100644 lib/zexbox/open_telemetry.ex delete mode 100644 test/zexbox/open_telemetry_test.exs diff --git a/lib/zexbox/open_telemetry.ex b/lib/zexbox/open_telemetry.ex deleted file mode 100644 index ca15d61..0000000 --- a/lib/zexbox/open_telemetry.ex +++ /dev/null @@ -1,131 +0,0 @@ -defmodule Zexbox.OpenTelemetry do - @moduledoc """ - OpenTelemetry URL helpers for enriching Jira tickets with observability links. - - Mirrors `Opsbox::OpenTelemetry`. Reads from the current process's OTEL context - (baggage and active span) via `opentelemetry_api`. - - All functions return `nil` gracefully when OTEL is unconfigured or there is no - active span, so they are safe to call from any rescue block. - - ## Configuration - - ```elixir - config :zexbox, - service_name: "my-app", - app_env: :production, # :production | :sandbox | :dev | :test (defaults to Mix.env()) - tempo_datasource_uid: nil # optional override for the Grafana Tempo datasource UID - ``` - """ - - @production_tempo_uid "een1tos42jnk0d" - @sandbox_tempo_uid "eehwibr22b6kgb" - @compile_env Mix.env() - - @doc """ - Returns `true` when the current OTEL span context is valid (non-zero trace ID). - """ - @spec valid_active_span?() :: boolean() - def valid_active_span? do - ctx = get_span_ctx() - ctx != nil && :otel_span.is_valid(ctx) - rescue - _e -> false - end - - @doc """ - Returns a Grafana Tempo trace URL for the current span, or `nil` if no active span - or if the environment is not `:production` or `:sandbox`. - """ - @spec generate_trace_url() :: String.t() | nil - def generate_trace_url do - if valid_active_span?() && app_env() in [:production, :sandbox] do - trace_id = hex_trace_id() - pane_json = build_pane_json(trace_id) - "https://zappi.grafana.net/explore?schemaVersion=1&panes=#{Jason.encode!(pane_json)}" - end - rescue - _e -> nil - end - - @doc """ - Returns a Kibana Discover URL for `:production` and `:sandbox` environments, `nil` otherwise. - Includes the current trace ID in the query when an active span is present. - """ - @spec kibana_log_url() :: String.t() | nil - def kibana_log_url do - env = app_env() - - if env in [:production, :sandbox] do - trace_id = if valid_active_span?(), do: hex_trace_id(), else: nil - service = Application.get_env(:zexbox, :service_name, "app") - trace_part = if trace_id, do: "%20AND%20%22#{trace_id}%22", else: "" - subdomain = if env == :production, do: "", else: "#{env}." - app_encoded = URI.encode(service, &URI.char_unreserved?/1) - - "https://kibana.#{subdomain}zappi.tools/app/discover#/?_a=(columns:!(log.message),filters:!()," <> - "query:(language:kuery,query:'zappi.app:%20%22#{app_encoded}%22#{trace_part}')," <> - "sort:!(!('@timestamp',asc)))&_g=(filters:!(),time:(from:now-1d,to:now))" - end - rescue - _e -> nil - end - - # --- Private --- - - defp get_span_ctx do - :otel_tracer.current_span_ctx() - rescue - _e -> nil - catch - _kind, _e -> nil - end - - defp hex_trace_id do - case get_span_ctx() do - nil -> - nil - - ctx -> - ctx - |> :otel_span.trace_id() - |> Integer.to_string(16) - |> String.pad_leading(32, "0") - |> String.downcase() - end - end - - defp build_pane_json(trace_id) do - uid = tempo_datasource_uid() - - %{ - plo: %{ - datasource: uid, - queries: [ - %{ - refId: "A", - datasource: %{type: "tempo", uid: uid}, - queryType: "traceql", - limit: 20, - tableType: "traces", - metricsQueryType: "range", - query: trace_id - } - ], - range: %{from: "now-1h", to: "now"} - } - } - end - - defp tempo_datasource_uid do - Application.get_env(:zexbox, :tempo_datasource_uid) || - case app_env() do - :production -> @production_tempo_uid - :sandbox -> @sandbox_tempo_uid - :test -> "test" - _env -> "development" - end - end - - defp app_env, do: Application.get_env(:zexbox, :app_env, @compile_env) -end diff --git a/test/zexbox/open_telemetry_test.exs b/test/zexbox/open_telemetry_test.exs deleted file mode 100644 index 1c51222..0000000 --- a/test/zexbox/open_telemetry_test.exs +++ /dev/null @@ -1,183 +0,0 @@ -defmodule Zexbox.OpenTelemetryTest do - use ExUnit.Case - import Mock - alias Zexbox.OpenTelemetry - - # A fake span context atom used as a stand-in for the opaque OTEL span record. - @mock_ctx :mock_span_ctx - - # Hex representation of the integer 255 padded to 32 chars. - @mock_trace_id_int 255 - @mock_trace_id_hex String.pad_leading("ff", 32, "0") - - defp with_active_span(fun) do - with_mocks([ - {:otel_tracer, [], [current_span_ctx: fn -> @mock_ctx end]}, - {:otel_span, [], - [is_valid: fn @mock_ctx -> true end, trace_id: fn @mock_ctx -> @mock_trace_id_int end]} - ]) do - fun.() - end - end - - defp with_no_span(fun) do - with_mocks([ - {:otel_tracer, [], [current_span_ctx: fn -> :undefined end]}, - {:otel_span, [], [is_valid: fn :undefined -> false end]} - ]) do - fun.() - end - end - - setup do - on_exit(fn -> - Application.delete_env(:zexbox, :app_env) - Application.delete_env(:zexbox, :service_name) - Application.delete_env(:zexbox, :tempo_datasource_uid) - end) - - :ok - end - - describe "valid_active_span?/0" do - test "returns true when the current span is valid" do - with_active_span(fn -> - assert OpenTelemetry.valid_active_span?() == true - end) - end - - test "returns false when there is no active span" do - with_no_span(fn -> - assert OpenTelemetry.valid_active_span?() == false - end) - end - - test "returns false when OTEL raises" do - with_mock :otel_tracer, current_span_ctx: fn -> raise "otel not running" end do - assert OpenTelemetry.valid_active_span?() == false - end - end - end - - describe "generate_trace_url/0" do - test "returns nil when there is no active span" do - with_no_span(fn -> - assert OpenTelemetry.generate_trace_url() == nil - end) - end - - test "returns a Grafana Tempo URL when there is an active span" do - Application.put_env(:zexbox, :app_env, :production) - - with_active_span(fn -> - url = OpenTelemetry.generate_trace_url() - assert url =~ "zappi.grafana.net" - assert url =~ @mock_trace_id_hex - end) - end - - test "Grafana URL encodes the pane JSON with the trace ID and datasource UID" do - Application.put_env(:zexbox, :app_env, :production) - Application.put_env(:zexbox, :tempo_datasource_uid, "test-uid") - - with_active_span(fn -> - url = OpenTelemetry.generate_trace_url() - assert url =~ "test-uid" - assert url =~ @mock_trace_id_hex - end) - end - - test "returns nil in non-production environments" do - Application.put_env(:zexbox, :app_env, :dev) - - with_active_span(fn -> - assert OpenTelemetry.generate_trace_url() == nil - end) - end - - test "returns nil when OTEL raises" do - with_mock :otel_tracer, current_span_ctx: fn -> raise "otel not running" end do - assert OpenTelemetry.generate_trace_url() == nil - end - end - end - - describe "kibana_log_url/0" do - test "returns nil for :test environment" do - Application.put_env(:zexbox, :app_env, :test) - - with_no_span(fn -> - assert OpenTelemetry.kibana_log_url() == nil - end) - end - - test "returns nil for :dev environment" do - Application.put_env(:zexbox, :app_env, :dev) - - with_no_span(fn -> - assert OpenTelemetry.kibana_log_url() == nil - end) - end - - test "returns a Kibana URL for :production environment" do - Application.put_env(:zexbox, :app_env, :production) - Application.put_env(:zexbox, :service_name, "my-app") - - with_no_span(fn -> - url = OpenTelemetry.kibana_log_url() - assert url =~ "kibana.zappi.tools" - assert url =~ "my-app" - refute url =~ "sandbox" - end) - end - - test "returns a Kibana URL for :sandbox environment with subdomain" do - Application.put_env(:zexbox, :app_env, :sandbox) - Application.put_env(:zexbox, :service_name, "my-app") - - with_no_span(fn -> - url = OpenTelemetry.kibana_log_url() - assert url =~ "kibana.sandbox.zappi.tools" - assert url =~ "my-app" - end) - end - - test "URL-encodes the service name" do - Application.put_env(:zexbox, :app_env, :production) - Application.put_env(:zexbox, :service_name, "my app") - - with_no_span(fn -> - url = OpenTelemetry.kibana_log_url() - assert url =~ "my%20app" - end) - end - - test "includes the trace ID in the URL when a span is active" do - Application.put_env(:zexbox, :app_env, :production) - - with_active_span(fn -> - url = OpenTelemetry.kibana_log_url() - assert url =~ @mock_trace_id_hex - end) - end - - test "omits the trace part when no active span" do - Application.put_env(:zexbox, :app_env, :production) - - with_no_span(fn -> - url = OpenTelemetry.kibana_log_url() - refute url =~ "AND" - end) - end - - test "omits trace ID when OTEL raises (returns URL without trace filter)" do - Application.put_env(:zexbox, :app_env, :production) - - with_mock :otel_tracer, current_span_ctx: fn -> raise "otel not running" end do - url = OpenTelemetry.kibana_log_url() - assert url =~ "kibana.zappi.tools" - refute url =~ "AND" - end - end - end -end From 4ed4e32b575f0ca680d61515c7eda4f5cf76f98a Mon Sep 17 00:00:00 2001 From: Jez-A Date: Mon, 9 Mar 2026 11:14:53 +0000 Subject: [PATCH 18/31] Cleanup mix too --- mix.exs | 2 -- mix.lock | 7 +++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 417f2c2..ccaf4b7 100644 --- a/mix.exs +++ b/mix.exs @@ -37,11 +37,9 @@ defmodule Zexbox.MixProject do {:doctor, "~> 0.22.0", only: [:dev, :test]}, {:ex_doc, "~> 0.35.1", only: :dev, runtime: false}, {:instream, "~> 2.2"}, - {:jason, "~> 1.4"}, {:ldclient, "~> 3.8.0", hex: :launchdarkly_server_sdk}, {:mix_audit, "~> 2.0", only: [:dev, :test], runtime: false}, {:mock, "~> 0.3.0", only: :test}, - {:opentelemetry_api, "~> 1.4"}, {:req, "~> 0.5"}, {:sobelow, "~> 0.8", only: [:dev, :test]}, {:telemetry, "~> 1.3"} 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 95a297410b80ff81e50c9138fd70fbca28e80346 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Mon, 9 Mar 2026 11:17:52 +0000 Subject: [PATCH 19/31] not needed --- mix.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/mix.lock b/mix.lock index 079c4b4..c7e589d 100644 --- a/mix.lock +++ b/mix.lock @@ -36,7 +36,6 @@ "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"}, From 8d572e61dfc4c4aa2a83cc0fbd0d5b1335705274 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Mon, 9 Mar 2026 14:02:40 +0000 Subject: [PATCH 20/31] Corrected fields type and cleanup --- lib/zexbox/jira_client.ex | 12 +++++------- test/zexbox/jira_client_test.exs | 8 ++++---- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/zexbox/jira_client.ex b/lib/zexbox/jira_client.ex index 4e70333..140909f 100644 --- a/lib/zexbox/jira_client.ex +++ b/lib/zexbox/jira_client.ex @@ -44,11 +44,11 @@ defmodule Zexbox.JiraClient do client = build_client() - case jira_post(client, "/rest/api/3/search/jql", %{ - "jql" => query, - "maxResults" => 50, - "fields" => ["key", "id", "self", "status", "summary"] - }) do + case jira_get(client, "/rest/api/3/issue/search", + jql: query, + maxResults: 50, + fields: ["key", "id", "self", "status", "summary"] + ) do {:ok, body} -> base_url = config(:jira_base_url, nil) issues = Map.get(body, "issues", []) @@ -136,8 +136,6 @@ defmodule Zexbox.JiraClient do jira_post(client, "/rest/api/3/issue/#{issue_key}/comment", %{"body" => comment}) end - # --- Private helpers --- - defp find_transition(transitions, status_name) do case Enum.find(transitions, fn t -> to_name = get_in(t, ["to", "name"]) || "" diff --git a/test/zexbox/jira_client_test.exs b/test/zexbox/jira_client_test.exs index 9bbd970..4936ab7 100644 --- a/test/zexbox/jira_client_test.exs +++ b/test/zexbox/jira_client_test.exs @@ -36,7 +36,7 @@ defmodule Zexbox.JiraClientTest do describe "search_latest_issues/2" do test_with_mock "returns issues with url keys added on success", Req, new: fn _opts -> :mock_client end, - post: fn :mock_client, _opts -> + get: fn :mock_client, _opts -> {:ok, %{ status: 200, @@ -58,7 +58,7 @@ defmodule Zexbox.JiraClientTest do test_with_mock "returns empty list when no issues found", Req, new: fn _opts -> :mock_client end, - post: fn :mock_client, _opts -> + get: fn :mock_client, _opts -> {:ok, %{status: 200, body: %{"issues" => []}}} end do assert {:ok, []} = JiraClient.search_latest_issues("status = Open") @@ -66,7 +66,7 @@ defmodule Zexbox.JiraClientTest do test_with_mock "returns error on non-2xx response", Req, new: fn _opts -> :mock_client end, - post: fn :mock_client, _opts -> + get: fn :mock_client, _opts -> {:ok, %{status: 401, body: %{"message" => "Unauthorized"}}} end do assert {:error, reason} = JiraClient.search_latest_issues("status = Open") @@ -75,7 +75,7 @@ defmodule Zexbox.JiraClientTest do test_with_mock "returns error on request failure", Req, new: fn _opts -> :mock_client end, - post: fn :mock_client, _opts -> + get: fn :mock_client, _opts -> {:error, %{reason: :econnrefused}} end do assert {:error, _reason} = JiraClient.search_latest_issues("status = Open") From 8d02399662d076f4f8c83a60a1ab8433054fae36 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Mon, 9 Mar 2026 14:11:20 +0000 Subject: [PATCH 21/31] Admin --- CHANGELOG.md | 4 ++++ mix.exs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72adff8..d0a69c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 1.5.2 - 2026-03-09 + +- Added the JiraClient, allowing us to create, search, comment on Jira tickets for support processes. + ## 1.5.1 - 2026-02-05 - Handles cases where `$callers` and `$ancestors` may not be pids to avoid crashing metric handler. diff --git a/mix.exs b/mix.exs index ccaf4b7..4422d48 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Zexbox.MixProject do def project do [ app: :zexbox, - version: "1.5.1", + version: "1.5.2", elixir: "~> 1.14", start_permanent: Mix.env() == :prod, dialyzer: [plt_add_apps: [:mix, :ex_unit]], From 84bde06fb275a72e9f125568450f96f8c40e2655 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 14:51:45 +0000 Subject: [PATCH 22/31] feat: Add AdfBuilder for Jira ADF document generation Adds Zexbox.AutoEscalation.AdfBuilder, which builds Atlassian Document Format (ADF) maps for new Jira issue descriptions and occurrence comments. Structure produced: - Telemetry paragraph (Datadog | Tempo | Kibana links, or "(Missing)") - Optional divider + custom description paragraphs - User context and additional context bullet lists - Error Details heading, exception class/message, expandable stack trace Both build_description/4 and build_comment/5 accept keyword opts for the optional custom_description and stacktrace arguments. Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/auto_escalation/adf_builder.ex | 189 +++++++++++++++ .../auto_escalation/adf_builder_test.exs | 220 ++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 lib/zexbox/auto_escalation/adf_builder.ex create mode 100644 test/zexbox/auto_escalation/adf_builder_test.exs diff --git a/lib/zexbox/auto_escalation/adf_builder.ex b/lib/zexbox/auto_escalation/adf_builder.ex new file mode 100644 index 0000000..ebe276e --- /dev/null +++ b/lib/zexbox/auto_escalation/adf_builder.ex @@ -0,0 +1,189 @@ +defmodule Zexbox.AutoEscalation.AdfBuilder do + @moduledoc """ + Builds Atlassian Document Format (ADF) maps for Jira issue descriptions and comments. + + Mirrors `Opsbox::AutoEscalation::AdfBuilder`. Produces the same ADF structure so + headings, links, bullet lists, and the stack-trace expand render correctly in Jira. + + Unlike the Ruby version, `stacktrace` must be passed explicitly (as `__STACKTRACE__` + from a `rescue` block) because Elixir exceptions do not carry their own backtrace. + """ + + alias Zexbox.OpenTelemetry + + @doc """ + Builds an ADF description map for a new Jira issue. + + Structure: + 1. Telemetry links (Datadog | Tempo | Kibana) + 2. Divider (if `custom_description` present) + 3. Custom description paragraphs (split on `\\n\\n`) + 4. User context bullet list (if non-empty) + 5. Additional context bullet list (if non-empty) + 6. H3 "Error Details" + 7. Exception summary + expandable stack trace + """ + @spec build_description( + Exception.t(), + map(), + map(), + keyword() + ) :: map() + def build_description(exception, user_context, additional_context, opts \\ []) do + custom_description = Keyword.get(opts, :custom_description) + stacktrace = Keyword.get(opts, :stacktrace) + + content = + [telemetry_paragraph()] + |> add_if(has_content?(custom_description), divider()) + |> Kernel.++(custom_description_blocks(custom_description)) + |> Kernel.++(context_blocks(user_context, additional_context)) + |> Kernel.++([heading(3, "Error Details")]) + |> Kernel.++(error_details_blocks(exception, stacktrace)) + + doc(content) + end + + @doc """ + Builds an ADF comment map for an additional occurrence on an existing Jira issue. + + Structure: + 1. H2 "Additional Occurrence (action)" + 2. Telemetry links + 3. Divider + custom description (if present) + 4. Context bullet lists (if non-empty) + 5. H3 "Error Details" + 6. Exception summary + expandable stack trace + """ + @spec build_comment( + Exception.t(), + String.t(), + map(), + map(), + keyword() + ) :: map() + def build_comment(exception, action, user_context, additional_context, opts \\ []) do + custom_description = Keyword.get(opts, :custom_description) + stacktrace = Keyword.get(opts, :stacktrace) + + content = + [heading(2, "Additional Occurrence (#{action})"), telemetry_paragraph()] + |> add_if(has_content?(custom_description), divider()) + |> Kernel.++(custom_description_blocks(custom_description)) + |> Kernel.++(context_blocks(user_context, additional_context)) + |> Kernel.++([heading(3, "Error Details")]) + |> Kernel.++(error_details_blocks(exception, stacktrace)) + + doc(content) + end + + # --- Private --- + + defp doc(content), do: %{version: 1, type: "doc", content: content} + + defp telemetry_paragraph do + dd_url = OpenTelemetry.datadog_session_url() + trace_url = OpenTelemetry.generate_trace_url() + kibana_url = OpenTelemetry.kibana_log_url() + + inline = + link_or_plain("Datadog Session Recording", dd_url) ++ + [text(" | ")] ++ + link_or_plain("Tempo Trace View", trace_url) ++ + [text(" | ")] ++ + link_or_plain("Kibana Logs", kibana_url) + + %{type: "paragraph", content: inline} + end + + defp link_or_plain(label, url) when is_binary(url) and url != "" do + [%{type: "text", text: label, marks: [%{type: "link", attrs: %{href: url}}]}] + end + + defp link_or_plain(label, _url) do + [%{type: "text", text: "#{label} (Missing)"}] + end + + defp text(str), do: %{type: "text", text: str} + defp bold(str), do: %{type: "text", text: to_string(str), marks: [%{type: "strong"}]} + defp divider, do: %{type: "rule"} + + defp heading(level, text_content), + do: %{type: "heading", attrs: %{level: level}, content: [text(text_content)]} + + defp code_block(content), + do: %{type: "codeBlock", content: [%{type: "text", text: to_string(content)}]} + + defp expand(title, content_blocks), + do: %{type: "expand", attrs: %{title: title}, content: content_blocks} + + defp custom_description_blocks(nil), do: [] + defp custom_description_blocks(""), do: [] + + defp custom_description_blocks(custom_description) do + custom_description + |> String.trim() + |> String.split(~r/\n\n+/) + |> Enum.map(fn paragraph -> + %{type: "paragraph", content: [text(String.trim(paragraph))]} + end) + end + + defp error_details_blocks(exception, stacktrace) do + error_class = inspect(exception.__struct__) + message = Exception.message(exception) + summary = "#{error_class}: #{message}" + + backtrace = + case stacktrace do + nil -> "No stack trace available" + [] -> "No stack trace available" + st -> Exception.format_stacktrace(st) + end + + [ + %{type: "paragraph", content: [text(summary)]}, + expand("Stack trace", [code_block(backtrace)]) + ] + end + + defp context_blocks(user_context, additional_context) do + [] + |> maybe_add_context("User Context", user_context) + |> maybe_add_context("Additional Context", additional_context) + end + + defp maybe_add_context(blocks, _label, ctx) when is_map(ctx) and map_size(ctx) == 0, do: blocks + defp maybe_add_context(blocks, _label, ctx) when not is_map(ctx), do: blocks + + defp maybe_add_context(blocks, label, ctx) do + blocks ++ + [%{type: "paragraph", content: [bold(label)]}] ++ + [key_value_bullet_list(ctx)] + end + + defp key_value_bullet_list(hash) do + items = + Enum.map(hash, fn {key, value} -> + %{ + type: "listItem", + content: [ + %{ + type: "paragraph", + content: [bold(key), text(": "), text(to_string(value))] + } + ] + } + end) + + %{type: "bulletList", content: items} + end + + defp add_if(list, true, item), do: list ++ [item] + defp add_if(list, false, _item), do: list + + defp has_content?(nil), do: false + defp has_content?(""), do: false + defp has_content?(str) when is_binary(str), do: String.trim(str) != "" + defp has_content?(_), do: false +end diff --git a/test/zexbox/auto_escalation/adf_builder_test.exs b/test/zexbox/auto_escalation/adf_builder_test.exs new file mode 100644 index 0000000..1566c5e --- /dev/null +++ b/test/zexbox/auto_escalation/adf_builder_test.exs @@ -0,0 +1,220 @@ +defmodule Zexbox.AutoEscalation.AdfBuilderTest do + use ExUnit.Case + import Mock + alias Zexbox.AutoEscalation.AdfBuilder + + defp with_telemetry_urls(dd, trace, kibana, fun) do + with_mocks([ + {Zexbox.OpenTelemetry, [], + [ + datadog_session_url: fn -> dd end, + generate_trace_url: fn -> trace end, + kibana_log_url: fn -> kibana end + ]} + ]) do + fun.() + end + end + + defp with_all_urls(fun), + do: + with_telemetry_urls( + "https://dd.example.com/session", + "https://grafana.example.com/trace", + "https://kibana.example.com/logs", + fun + ) + + defp with_no_urls(fun), do: with_telemetry_urls(nil, nil, nil, fun) + + defp runtime_error(msg \\ "Something broke"), do: %RuntimeError{message: msg} + + describe "build_description/5" do + test "returns an ADF doc map with correct version and type" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{}) + assert result.version == 1 + assert result.type == "doc" + assert is_list(result.content) + end) + end + + test "first block is a telemetry paragraph with all three links" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{}) + [telemetry | _] = result.content + assert telemetry.type == "paragraph" + json = Jason.encode!(telemetry) + assert json =~ "Datadog Session Recording" + assert json =~ "https://dd.example.com/session" + assert json =~ "Tempo Trace View" + assert json =~ "https://grafana.example.com/trace" + assert json =~ "Kibana Logs" + assert json =~ "https://kibana.example.com/logs" + end) + end + + test "shows '(Missing)' for unavailable telemetry URLs" do + with_no_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{}) + [telemetry | _] = result.content + json = Jason.encode!(telemetry) + assert json =~ "Datadog Session Recording (Missing)" + assert json =~ "Tempo Trace View (Missing)" + assert json =~ "Kibana Logs (Missing)" + end) + end + + test "includes Error Details heading, exception class, message, and stack trace expand" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error("boom"), %{}, %{}) + json = Jason.encode!(result) + assert json =~ "Error Details" + assert json =~ "RuntimeError" + assert json =~ "boom" + assert json =~ "Stack trace" + assert json =~ "No stack trace available" + end) + end + + test "formats provided stacktrace in the expand block" do + with_all_urls(fn -> + stacktrace = [{MyModule, :my_fn, 2, [file: ~c"lib/my_module.ex", line: 42]}] + result = AdfBuilder.build_description(runtime_error(), %{}, %{}, stacktrace: stacktrace) + json = Jason.encode!(result) + assert json =~ "Stack trace" + assert json =~ "my_module.ex" + end) + end + + test "includes custom_description paragraphs above Error Details" do + with_all_urls(fn -> + result = + AdfBuilder.build_description(runtime_error(), %{}, %{}, + custom_description: "This happened during sync." + ) + + json = Jason.encode!(result) + assert json =~ "This happened during sync." + assert json =~ "Error Details" + + {cd_pos, _} = :binary.match(json, "This happened during sync.") + {ed_pos, _} = :binary.match(json, "Error Details") + assert cd_pos < ed_pos + end) + end + + test "splits custom_description on double newlines into multiple paragraphs" do + with_all_urls(fn -> + result = + AdfBuilder.build_description(runtime_error(), %{}, %{}, + custom_description: "First.\n\nSecond." + ) + json = Jason.encode!(result) + assert json =~ "First." + assert json =~ "Second." + end) + end + + test "adds a divider before custom_description when present" do + with_all_urls(fn -> + result = + AdfBuilder.build_description(runtime_error(), %{}, %{}, custom_description: "Some context.") + json = Jason.encode!(result) + assert json =~ ~s("type":"rule") + end) + end + + test "does not add a divider when custom_description is nil" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{}) + json = Jason.encode!(result) + refute json =~ ~s("type":"rule") + end) + end + + test "includes user_context as a bold key-value bullet list" do + with_all_urls(fn -> + result = + AdfBuilder.build_description(runtime_error(), %{email: "u@example.com"}, %{}) + + json = Jason.encode!(result) + assert json =~ "User Context" + assert json =~ "email" + assert json =~ "u@example.com" + end) + end + + test "includes additional_context as a bold key-value bullet list" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{basket_id: 42}) + json = Jason.encode!(result) + assert json =~ "Additional Context" + assert json =~ "basket_id" + assert json =~ "42" + end) + end + + test "omits User Context section when empty" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{}) + json = Jason.encode!(result) + refute json =~ "User Context" + end) + end + + test "omits Additional Context section when empty" do + with_all_urls(fn -> + result = AdfBuilder.build_description(runtime_error(), %{}, %{}) + json = Jason.encode!(result) + refute json =~ "Additional Context" + end) + end + end + + describe "build_comment/6" do + test "starts with an Additional Occurrence heading including action name" do + with_all_urls(fn -> + result = AdfBuilder.build_comment(runtime_error(), "checkout", %{}, %{}) + json = Jason.encode!(result) + assert json =~ "Additional Occurrence (checkout)" + end) + end + + test "includes telemetry paragraph" do + with_all_urls(fn -> + result = AdfBuilder.build_comment(runtime_error(), "pay", %{}, %{}) + json = Jason.encode!(result) + assert json =~ "Datadog Session Recording" + end) + end + + test "includes exception details and stack trace" do + with_all_urls(fn -> + result = AdfBuilder.build_comment(runtime_error("boom"), "pay", %{}, %{}) + json = Jason.encode!(result) + assert json =~ "RuntimeError" + assert json =~ "boom" + assert json =~ "Stack trace" + end) + end + + test "includes context bullet lists when provided" do + with_all_urls(fn -> + result = + AdfBuilder.build_comment( + runtime_error(), + "checkout", + %{email: "u@example.com"}, + %{basket_id: 123} + ) + + json = Jason.encode!(result) + assert json =~ "User Context" + assert json =~ "u@example.com" + assert json =~ "Additional Context" + assert json =~ "123" + end) + end + end +end From 205e9916a74229dde2187912f34d383608c44343 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 14:58:43 +0000 Subject: [PATCH 23/31] style: Run mix format Co-Authored-By: Claude Sonnet 4.6 --- test/zexbox/auto_escalation/adf_builder_test.exs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/zexbox/auto_escalation/adf_builder_test.exs b/test/zexbox/auto_escalation/adf_builder_test.exs index 1566c5e..d4c749d 100644 --- a/test/zexbox/auto_escalation/adf_builder_test.exs +++ b/test/zexbox/auto_escalation/adf_builder_test.exs @@ -110,6 +110,7 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do AdfBuilder.build_description(runtime_error(), %{}, %{}, custom_description: "First.\n\nSecond." ) + json = Jason.encode!(result) assert json =~ "First." assert json =~ "Second." @@ -119,7 +120,10 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "adds a divider before custom_description when present" do with_all_urls(fn -> result = - AdfBuilder.build_description(runtime_error(), %{}, %{}, custom_description: "Some context.") + AdfBuilder.build_description(runtime_error(), %{}, %{}, + custom_description: "Some context." + ) + json = Jason.encode!(result) assert json =~ ~s("type":"rule") end) From 1e5ba255a64c9cc592dd071ca2694bcc9cff2e3e Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 3 Mar 2026 17:07:16 +0000 Subject: [PATCH 24/31] feat: Remove Datadog session link from telemetry paragraph Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/auto_escalation/adf_builder.ex | 5 +---- test/zexbox/auto_escalation/adf_builder_test.exs | 13 ++++--------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/lib/zexbox/auto_escalation/adf_builder.ex b/lib/zexbox/auto_escalation/adf_builder.ex index ebe276e..f99f0e2 100644 --- a/lib/zexbox/auto_escalation/adf_builder.ex +++ b/lib/zexbox/auto_escalation/adf_builder.ex @@ -82,14 +82,11 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do defp doc(content), do: %{version: 1, type: "doc", content: content} defp telemetry_paragraph do - dd_url = OpenTelemetry.datadog_session_url() trace_url = OpenTelemetry.generate_trace_url() kibana_url = OpenTelemetry.kibana_log_url() inline = - link_or_plain("Datadog Session Recording", dd_url) ++ - [text(" | ")] ++ - link_or_plain("Tempo Trace View", trace_url) ++ + link_or_plain("Tempo Trace View", trace_url) ++ [text(" | ")] ++ link_or_plain("Kibana Logs", kibana_url) diff --git a/test/zexbox/auto_escalation/adf_builder_test.exs b/test/zexbox/auto_escalation/adf_builder_test.exs index d4c749d..33a85cd 100644 --- a/test/zexbox/auto_escalation/adf_builder_test.exs +++ b/test/zexbox/auto_escalation/adf_builder_test.exs @@ -3,11 +3,10 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do import Mock alias Zexbox.AutoEscalation.AdfBuilder - defp with_telemetry_urls(dd, trace, kibana, fun) do + defp with_telemetry_urls(trace, kibana, fun) do with_mocks([ {Zexbox.OpenTelemetry, [], [ - datadog_session_url: fn -> dd end, generate_trace_url: fn -> trace end, kibana_log_url: fn -> kibana end ]} @@ -19,13 +18,12 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do defp with_all_urls(fun), do: with_telemetry_urls( - "https://dd.example.com/session", "https://grafana.example.com/trace", "https://kibana.example.com/logs", fun ) - defp with_no_urls(fun), do: with_telemetry_urls(nil, nil, nil, fun) + defp with_no_urls(fun), do: with_telemetry_urls(nil, nil, fun) defp runtime_error(msg \\ "Something broke"), do: %RuntimeError{message: msg} @@ -39,14 +37,12 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do end) end - test "first block is a telemetry paragraph with all three links" do + test "first block is a telemetry paragraph with Tempo and Kibana links" do with_all_urls(fn -> result = AdfBuilder.build_description(runtime_error(), %{}, %{}) [telemetry | _] = result.content assert telemetry.type == "paragraph" json = Jason.encode!(telemetry) - assert json =~ "Datadog Session Recording" - assert json =~ "https://dd.example.com/session" assert json =~ "Tempo Trace View" assert json =~ "https://grafana.example.com/trace" assert json =~ "Kibana Logs" @@ -59,7 +55,6 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do result = AdfBuilder.build_description(runtime_error(), %{}, %{}) [telemetry | _] = result.content json = Jason.encode!(telemetry) - assert json =~ "Datadog Session Recording (Missing)" assert json =~ "Tempo Trace View (Missing)" assert json =~ "Kibana Logs (Missing)" end) @@ -189,7 +184,7 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do with_all_urls(fn -> result = AdfBuilder.build_comment(runtime_error(), "pay", %{}, %{}) json = Jason.encode!(result) - assert json =~ "Datadog Session Recording" + assert json =~ "Tempo Trace View" end) end From 9fc0615242a330ff57aee05e0eb0c9e8e64e13f8 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Mon, 9 Mar 2026 10:17:08 +0000 Subject: [PATCH 25/31] fix: Name bare _ wildcards to satisfy Credo consistency check Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/auto_escalation/adf_builder.ex | 2 +- test/zexbox/auto_escalation/adf_builder_test.exs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/zexbox/auto_escalation/adf_builder.ex b/lib/zexbox/auto_escalation/adf_builder.ex index f99f0e2..b05231d 100644 --- a/lib/zexbox/auto_escalation/adf_builder.ex +++ b/lib/zexbox/auto_escalation/adf_builder.ex @@ -182,5 +182,5 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do defp has_content?(nil), do: false defp has_content?(""), do: false defp has_content?(str) when is_binary(str), do: String.trim(str) != "" - defp has_content?(_), do: false + defp has_content?(_other), do: false end diff --git a/test/zexbox/auto_escalation/adf_builder_test.exs b/test/zexbox/auto_escalation/adf_builder_test.exs index 33a85cd..e74d7e4 100644 --- a/test/zexbox/auto_escalation/adf_builder_test.exs +++ b/test/zexbox/auto_escalation/adf_builder_test.exs @@ -40,7 +40,7 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "first block is a telemetry paragraph with Tempo and Kibana links" do with_all_urls(fn -> result = AdfBuilder.build_description(runtime_error(), %{}, %{}) - [telemetry | _] = result.content + [telemetry | _rest] = result.content assert telemetry.type == "paragraph" json = Jason.encode!(telemetry) assert json =~ "Tempo Trace View" @@ -53,7 +53,7 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "shows '(Missing)' for unavailable telemetry URLs" do with_no_urls(fn -> result = AdfBuilder.build_description(runtime_error(), %{}, %{}) - [telemetry | _] = result.content + [telemetry | _rest] = result.content json = Jason.encode!(telemetry) assert json =~ "Tempo Trace View (Missing)" assert json =~ "Kibana Logs (Missing)" @@ -93,8 +93,8 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do assert json =~ "This happened during sync." assert json =~ "Error Details" - {cd_pos, _} = :binary.match(json, "This happened during sync.") - {ed_pos, _} = :binary.match(json, "Error Details") + {cd_pos, _len} = :binary.match(json, "This happened during sync.") + {ed_pos, _len} = :binary.match(json, "Error Details") assert cd_pos < ed_pos end) end From 761cacb9f88c2ce5fb17a0530a6d776042c878b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Aubert?= Date: Mon, 9 Mar 2026 15:59:50 +0000 Subject: [PATCH 26/31] feat: Add Zexbox.JiraClient (#53) * feat: Add Zexbox.JiraClient to link error handling with support processes. --- CHANGELOG.md | 4 + lib/zexbox/jira_client.ex | 193 +++++++++++++++++++++++++++++++ mix.exs | 3 +- mix.lock | 7 ++ test/zexbox/jira_client_test.exs | 186 +++++++++++++++++++++++++++++ 5 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 lib/zexbox/jira_client.ex create mode 100644 test/zexbox/jira_client_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 72adff8..d0a69c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 1.5.2 - 2026-03-09 + +- Added the JiraClient, allowing us to create, search, comment on Jira tickets for support processes. + ## 1.5.1 - 2026-02-05 - Handles cases where `$callers` and `$ancestors` may not be pids to avoid crashing metric handler. diff --git a/lib/zexbox/jira_client.ex b/lib/zexbox/jira_client.ex new file mode 100644 index 0000000..7ad5075 --- /dev/null +++ b/lib/zexbox/jira_client.ex @@ -0,0 +1,193 @@ +defmodule Zexbox.JiraClient do + @moduledoc """ + HTTP client for the Jira Cloud REST API v3. + + Mirrors `Opsbox::JiraClient`. Authenticates with Basic auth using + `JIRA_USER_EMAIL_ADDRESS` and `JIRA_API_TOKEN` environment variables + (or `:jira_email` / `:jira_api_token` application config). + + ## Configuration + + ```elixir + config :zexbox, + jira_base_url: "https://your-org.atlassian.net", + jira_email: System.get_env("JIRA_USER_EMAIL_ADDRESS"), + jira_api_token: System.get_env("JIRA_API_TOKEN") + ``` + + All public functions return `{:ok, result}` or `{:error, reason}`. + """ + + @bug_fingerprint_field %{id: "customfield_13442", name: "Bug Fingerprint[Short text]"} + @zigl_team_field %{id: "customfield_10101", name: "ZIGL Team[Dropdown]"} + + @doc "Returns the bug fingerprint custom field metadata." + @spec bug_fingerprint_field() :: %{id: String.t(), name: String.t()} + def bug_fingerprint_field, do: @bug_fingerprint_field + + @doc "Returns the ZIGL team custom field metadata." + @spec zigl_team_field() :: %{id: String.t(), name: String.t()} + def zigl_team_field, do: @zigl_team_field + + @doc """ + Search for the latest issues matching a JQL query (max 50 results). + + - `jql` – JQL query string. + - `project_key` – optional; prepends `project = KEY AND` to the JQL. + + Returns `{:ok, [issue_map]}` where each map includes a `"url"` browse key, + or `{:error, reason}` on failure. + """ + @spec search_latest_issues(String.t(), String.t() | nil) :: {:ok, [map()]} | {:error, term()} + def search_latest_issues(jql, project_key \\ nil) do + fetch_issues(build_query(jql, project_key)) + end + + @doc """ + Create a new Jira issue. + + - `project_key` – Jira project key (e.g. `"SS"`). + - `summary` – issue summary string. + - `description` – ADF map (already built; not converted). + - `issuetype` – issue type name (e.g. `"Bug"`). + - `priority` – priority name (e.g. `"High"`). + - `custom_fields` – optional map of custom field ID → value (string keys). + + Returns `{:ok, issue_map}` with a `"url"` browse key added, or `{:error, reason}`. + """ + @spec create_issue(String.t(), String.t(), map(), String.t(), String.t(), map()) :: + {:ok, map()} | {:error, term()} + def create_issue(project_key, summary, description, issuetype, priority, custom_fields \\ %{}) do + fields = + Map.merge( + %{ + "project" => %{"key" => project_key}, + "summary" => summary, + "description" => description, + "issuetype" => %{"name" => issuetype}, + "priority" => %{"name" => priority} + }, + custom_fields + ) + + post_issue(fields) + end + + @doc """ + Transition a Jira issue to a new status by name (case-insensitive match). + + - `issue_key` – issue key (e.g. `"SS-42"`). + - `status_name` – target status name (e.g. `"To do"`). + + Returns `{:ok, %{success: true, status: name}}` or `{:error, reason}`. + """ + @spec transition_issue(String.t(), String.t()) :: {:ok, map()} | {:error, term()} + def transition_issue(issue_key, status_name) do + client = build_client() + + with {:ok, data} <- jira_get(client, "/rest/api/3/issue/#{issue_key}/transitions"), + transitions = Map.get(data, "transitions", []), + {:ok, target} <- find_transition(transitions, status_name), + {:ok, _resp} <- + jira_post(client, "/rest/api/3/issue/#{issue_key}/transitions", %{ + "transition" => %{"id" => target["id"]} + }) do + {:ok, %{success: true, status: get_in(target, ["to", "name"])}} + end + end + + @doc """ + Add a comment to an existing Jira issue. + + - `issue_key` – issue key (e.g. `"SS-42"`). + - `comment` – ADF map for the comment body (already built; not converted). + + Returns `{:ok, comment_map}` or `{:error, reason}`. + """ + @spec add_comment(String.t(), map()) :: {:ok, map()} | {:error, term()} + def add_comment(issue_key, comment), do: post_comment(issue_key, comment) + + # --- Private --- + + defp build_query(jql, nil), do: jql + defp build_query(jql, project_key), do: "project = #{project_key} AND #{jql}" + + defp fetch_issues(query) do + build_client() + |> jira_get("/rest/api/3/issue/search", + jql: query, + maxResults: 50, + fields: ["key", "id", "self", "status", "summary"] + ) + |> attach_issue_urls() + end + + defp post_issue(fields) do + build_client() + |> jira_post("/rest/api/3/issue", %{"fields" => fields}) + |> attach_issue_url() + end + + defp post_comment(issue_key, comment) do + build_client() + |> jira_post("/rest/api/3/issue/#{issue_key}/comment", %{"body" => comment}) + end + + defp attach_issue_urls({:ok, body}) do + issues = Map.get(body, "issues", []) + {:ok, Enum.map(issues, &Map.put(&1, "url", browse_url(&1["key"])))} + end + + defp attach_issue_urls({:error, _reason} = err), do: err + + defp attach_issue_url({:ok, result}), + do: {:ok, Map.put(result, "url", browse_url(result["key"]))} + + defp attach_issue_url({:error, _reason} = err), do: err + + defp browse_url(key), do: "#{config(:jira_base_url, nil)}/browse/#{key}" + + defp find_transition(transitions, status_name) do + case Enum.find(transitions, &matches_status?(&1, status_name)) do + nil -> {:error, "Cannot transition to '#{status_name}'"} + target -> {:ok, target} + end + end + + defp matches_status?(transition, status_name) do + to_name = get_in(transition, ["to", "name"]) |> to_string() + String.downcase(to_name) == String.downcase(status_name) + end + + defp jira_get(client, path, params \\ []) do + Req.get(client, url: path, params: params) + |> handle_response() + end + + defp jira_post(client, path, body) do + Req.post(client, url: path, json: body) + |> handle_response() + end + + defp handle_response({:ok, %{status: status, body: body}}) when status in 200..299, + do: {:ok, body || %{}} + + defp handle_response({:ok, %{status: status, body: body}}), + do: {:error, "HTTP #{status}: #{inspect(body)}"} + + defp handle_response({:error, reason}), + do: {:error, inspect(reason)} + + defp build_client do + email = config(:jira_email, System.get_env("JIRA_USER_EMAIL_ADDRESS", "")) + token = config(:jira_api_token, System.get_env("JIRA_API_TOKEN", "")) + + Req.new( + base_url: config(:jira_base_url, nil), + auth: {:basic, "#{email}:#{token}"}, + headers: [{"accept", "application/json"}] + ) + end + + defp config(key, default), do: Application.get_env(:zexbox, key, default) +end diff --git a/mix.exs b/mix.exs index 5e5dcb2..4422d48 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Zexbox.MixProject do def project do [ app: :zexbox, - version: "1.5.1", + version: "1.5.2", elixir: "~> 1.14", start_permanent: Mix.env() == :prod, dialyzer: [plt_add_apps: [:mix, :ex_unit]], @@ -40,6 +40,7 @@ defmodule Zexbox.MixProject do {:ldclient, "~> 3.8.0", hex: :launchdarkly_server_sdk}, {:mix_audit, "~> 2.0", only: [:dev, :test], runtime: false}, {:mock, "~> 0.3.0", only: :test}, + {:req, "~> 0.5"}, {:sobelow, "~> 0.8", only: [:dev, :test]}, {:telemetry, "~> 1.3"} ] diff --git a/mix.lock b/mix.lock index f3472fc..c7e589d 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,14 +27,19 @@ "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"}, "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/jira_client_test.exs b/test/zexbox/jira_client_test.exs new file mode 100644 index 0000000..4936ab7 --- /dev/null +++ b/test/zexbox/jira_client_test.exs @@ -0,0 +1,186 @@ +defmodule Zexbox.JiraClientTest do + use ExUnit.Case + import Mock + alias Zexbox.JiraClient + + @base_url "https://zigroup.atlassian.net" + + setup do + Application.put_env(:zexbox, :jira_base_url, @base_url) + Application.put_env(:zexbox, :jira_email, "test@example.com") + Application.put_env(:zexbox, :jira_api_token, "test-token") + + on_exit(fn -> + Application.delete_env(:zexbox, :jira_base_url) + Application.delete_env(:zexbox, :jira_email) + Application.delete_env(:zexbox, :jira_api_token) + end) + + :ok + end + + describe "bug_fingerprint_field/0" do + test "returns the correct field metadata" do + assert %{id: "customfield_13442", name: "Bug Fingerprint[Short text]"} = + JiraClient.bug_fingerprint_field() + end + end + + describe "zigl_team_field/0" do + test "returns the correct field metadata" do + assert %{id: "customfield_10101", name: "ZIGL Team[Dropdown]"} = + JiraClient.zigl_team_field() + end + end + + describe "search_latest_issues/2" do + test_with_mock "returns issues with url keys added on success", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "issues" => [ + %{ + "key" => "SS-1", + "id" => "10001", + "self" => "#{@base_url}/rest/api/3/issue/10001" + } + ] + } + }} + end do + assert {:ok, [issue]} = JiraClient.search_latest_issues("status = Open", "SS") + assert issue["key"] == "SS-1" + assert issue["url"] == "#{@base_url}/browse/SS-1" + end + + test_with_mock "returns empty list when no issues found", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, %{status: 200, body: %{"issues" => []}}} + end do + assert {:ok, []} = JiraClient.search_latest_issues("status = Open") + end + + test_with_mock "returns error on non-2xx response", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, %{status: 401, body: %{"message" => "Unauthorized"}}} + end do + assert {:error, reason} = JiraClient.search_latest_issues("status = Open") + assert reason =~ "HTTP 401" + end + + test_with_mock "returns error on request failure", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:error, %{reason: :econnrefused}} + end do + assert {:error, _reason} = JiraClient.search_latest_issues("status = Open") + end + end + + describe "create_issue/6" do + test_with_mock "creates issue and adds url to result", Req, + new: fn _opts -> :mock_client end, + post: fn :mock_client, _opts -> + {:ok, + %{ + status: 201, + body: %{ + "key" => "SS-99", + "id" => "10099", + "self" => "#{@base_url}/rest/api/3/issue/10099" + } + }} + end do + assert {:ok, result} = + JiraClient.create_issue( + "SS", + "checkout: RuntimeError", + %{version: 1, type: "doc", content: []}, + "Bug", + "High", + %{"customfield_13442" => "checkout::RuntimeError"} + ) + + assert result["key"] == "SS-99" + assert result["url"] == "#{@base_url}/browse/SS-99" + end + + test_with_mock "returns error on non-2xx response", Req, + new: fn _opts -> :mock_client end, + post: fn :mock_client, _opts -> + {:ok, %{status: 400, body: %{"errorMessages" => ["Invalid project"]}}} + end do + assert {:error, reason} = + JiraClient.create_issue("INVALID", "test", %{}, "Bug", "High") + + assert reason =~ "HTTP 400" + end + end + + describe "transition_issue/2" do + test_with_mock "transitions issue to target status", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "transitions" => [ + %{"id" => "11", "to" => %{"name" => "To do"}}, + %{"id" => "21", "to" => %{"name" => "In Progress"}} + ] + } + }} + end, + post: fn :mock_client, opts -> + assert opts[:json] == %{"transition" => %{"id" => "11"}} + {:ok, %{status: 204, body: nil}} + end do + assert {:ok, %{success: true, status: "To do"}} = + JiraClient.transition_issue("SS-1", "To do") + end + + test_with_mock "returns error when target status not found", Req, + new: fn _opts -> :mock_client end, + get: fn :mock_client, _opts -> + {:ok, + %{ + status: 200, + body: %{"transitions" => [%{"id" => "11", "to" => %{"name" => "Done"}}]} + }} + end do + assert {:error, reason} = JiraClient.transition_issue("SS-1", "Nonexistent") + assert reason =~ "Cannot transition to" + end + end + + describe "add_comment/2" do + test_with_mock "adds comment and returns the response", Req, + new: fn _opts -> :mock_client end, + post: fn :mock_client, _opts -> + {:ok, + %{ + status: 201, + body: %{"id" => "30001", "body" => %{}, "author" => %{"displayName" => "Bot"}} + }} + end do + comment = %{version: 1, type: "doc", content: []} + assert {:ok, result} = JiraClient.add_comment("SS-42", comment) + assert result["id"] == "30001" + end + + test_with_mock "returns error on non-2xx response", Req, + new: fn _opts -> :mock_client end, + post: fn :mock_client, _opts -> + {:ok, %{status: 404, body: %{"errorMessages" => ["Issue not found"]}}} + end do + assert {:error, reason} = JiraClient.add_comment("SS-999", %{}) + assert reason =~ "HTTP 404" + end + end +end From 9d66a3f2441f9650991ca4ec0ab99ef6edb80c00 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Mon, 9 Mar 2026 16:10:22 +0000 Subject: [PATCH 27/31] Conflict resolutions --- test/zexbox/jira_client_test.exs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/zexbox/jira_client_test.exs b/test/zexbox/jira_client_test.exs index 9bbd970..4936ab7 100644 --- a/test/zexbox/jira_client_test.exs +++ b/test/zexbox/jira_client_test.exs @@ -36,7 +36,7 @@ defmodule Zexbox.JiraClientTest do describe "search_latest_issues/2" do test_with_mock "returns issues with url keys added on success", Req, new: fn _opts -> :mock_client end, - post: fn :mock_client, _opts -> + get: fn :mock_client, _opts -> {:ok, %{ status: 200, @@ -58,7 +58,7 @@ defmodule Zexbox.JiraClientTest do test_with_mock "returns empty list when no issues found", Req, new: fn _opts -> :mock_client end, - post: fn :mock_client, _opts -> + get: fn :mock_client, _opts -> {:ok, %{status: 200, body: %{"issues" => []}}} end do assert {:ok, []} = JiraClient.search_latest_issues("status = Open") @@ -66,7 +66,7 @@ defmodule Zexbox.JiraClientTest do test_with_mock "returns error on non-2xx response", Req, new: fn _opts -> :mock_client end, - post: fn :mock_client, _opts -> + get: fn :mock_client, _opts -> {:ok, %{status: 401, body: %{"message" => "Unauthorized"}}} end do assert {:error, reason} = JiraClient.search_latest_issues("status = Open") @@ -75,7 +75,7 @@ defmodule Zexbox.JiraClientTest do test_with_mock "returns error on request failure", Req, new: fn _opts -> :mock_client end, - post: fn :mock_client, _opts -> + get: fn :mock_client, _opts -> {:error, %{reason: :econnrefused}} end do assert {:error, _reason} = JiraClient.search_latest_issues("status = Open") From 5bd453420d5dfb299dc85a3e278f05bd9917e25a Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 10 Mar 2026 11:13:27 +0000 Subject: [PATCH 28/31] refactor: Convert AdfBuilder public API to explicit positional arguments Replace opts keyword list with explicit stacktrace and custom_description positional arguments in build_description/5 and build_comment/6. Simplify internal list-building from Kernel.++ pipe chains to plain ++ expressions, and consolidate add_if/has_content? helpers into optional_description_blocks and single_context_blocks. Restore Zexbox.OpenTelemetry (from stacked PR) so the module is available to compile and mock in tests. Fix duplicate dependency entries in mix.exs. All 60 tests pass; credo and dialyzer clean. Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/auto_escalation/adf_builder.ex | 79 +++++----- lib/zexbox/open_telemetry.ex | 136 ++++++++++++++++++ mix.exs | 3 - mix.lock | 1 + .../auto_escalation/adf_builder_test.exs | 14 +- 5 files changed, 178 insertions(+), 55 deletions(-) create mode 100644 lib/zexbox/open_telemetry.ex diff --git a/lib/zexbox/auto_escalation/adf_builder.ex b/lib/zexbox/auto_escalation/adf_builder.ex index b05231d..d8a9afd 100644 --- a/lib/zexbox/auto_escalation/adf_builder.ex +++ b/lib/zexbox/auto_escalation/adf_builder.ex @@ -15,7 +15,7 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do Builds an ADF description map for a new Jira issue. Structure: - 1. Telemetry links (Datadog | Tempo | Kibana) + 1. Telemetry links (Tempo | Kibana) 2. Divider (if `custom_description` present) 3. Custom description paragraphs (split on `\\n\\n`) 4. User context bullet list (if non-empty) @@ -27,19 +27,16 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do Exception.t(), map(), map(), - keyword() + Exception.stacktrace() | nil, + String.t() | nil ) :: map() - def build_description(exception, user_context, additional_context, opts \\ []) do - custom_description = Keyword.get(opts, :custom_description) - stacktrace = Keyword.get(opts, :stacktrace) - + def build_description(exception, user_context, additional_context, stacktrace \\ nil, custom_description \\ nil) do content = - [telemetry_paragraph()] - |> add_if(has_content?(custom_description), divider()) - |> Kernel.++(custom_description_blocks(custom_description)) - |> Kernel.++(context_blocks(user_context, additional_context)) - |> Kernel.++([heading(3, "Error Details")]) - |> Kernel.++(error_details_blocks(exception, stacktrace)) + [telemetry_paragraph()] ++ + optional_description_blocks(custom_description) ++ + context_blocks(user_context, additional_context) ++ + [heading(3, "Error Details")] ++ + error_details_blocks(exception, stacktrace) doc(content) end @@ -60,19 +57,16 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do String.t(), map(), map(), - keyword() + Exception.stacktrace() | nil, + String.t() | nil ) :: map() - def build_comment(exception, action, user_context, additional_context, opts \\ []) do - custom_description = Keyword.get(opts, :custom_description) - stacktrace = Keyword.get(opts, :stacktrace) - + def build_comment(exception, action, user_context, additional_context, stacktrace \\ nil, custom_description \\ nil) do content = - [heading(2, "Additional Occurrence (#{action})"), telemetry_paragraph()] - |> add_if(has_content?(custom_description), divider()) - |> Kernel.++(custom_description_blocks(custom_description)) - |> Kernel.++(context_blocks(user_context, additional_context)) - |> Kernel.++([heading(3, "Error Details")]) - |> Kernel.++(error_details_blocks(exception, stacktrace)) + [heading(2, "Additional Occurrence (#{action})"), telemetry_paragraph()] ++ + optional_description_blocks(custom_description) ++ + context_blocks(user_context, additional_context) ++ + [heading(3, "Error Details")] ++ + error_details_blocks(exception, stacktrace) doc(content) end @@ -114,12 +108,18 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do defp expand(title, content_blocks), do: %{type: "expand", attrs: %{title: title}, content: content_blocks} - defp custom_description_blocks(nil), do: [] - defp custom_description_blocks(""), do: [] + defp optional_description_blocks(nil), do: [] + defp optional_description_blocks(""), do: [] + + defp optional_description_blocks(desc) do + case String.trim(desc) do + "" -> [] + trimmed -> [divider() | custom_description_blocks(trimmed)] + end + end - defp custom_description_blocks(custom_description) do - custom_description - |> String.trim() + defp custom_description_blocks(desc) do + desc |> String.split(~r/\n\n+/) |> Enum.map(fn paragraph -> %{type: "paragraph", content: [text(String.trim(paragraph))]} @@ -145,18 +145,15 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do end defp context_blocks(user_context, additional_context) do - [] - |> maybe_add_context("User Context", user_context) - |> maybe_add_context("Additional Context", additional_context) + single_context_blocks("User Context", user_context) ++ + single_context_blocks("Additional Context", additional_context) end - defp maybe_add_context(blocks, _label, ctx) when is_map(ctx) and map_size(ctx) == 0, do: blocks - defp maybe_add_context(blocks, _label, ctx) when not is_map(ctx), do: blocks + defp single_context_blocks(_label, ctx) when not is_map(ctx), do: [] + defp single_context_blocks(_label, ctx) when map_size(ctx) == 0, do: [] - defp maybe_add_context(blocks, label, ctx) do - blocks ++ - [%{type: "paragraph", content: [bold(label)]}] ++ - [key_value_bullet_list(ctx)] + defp single_context_blocks(label, ctx) do + [%{type: "paragraph", content: [bold(label)]}, key_value_bullet_list(ctx)] end defp key_value_bullet_list(hash) do @@ -175,12 +172,4 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do %{type: "bulletList", content: items} end - - defp add_if(list, true, item), do: list ++ [item] - defp add_if(list, false, _item), do: list - - defp has_content?(nil), do: false - defp has_content?(""), do: false - defp has_content?(str) when is_binary(str), do: String.trim(str) != "" - defp has_content?(_other), do: false end diff --git a/lib/zexbox/open_telemetry.ex b/lib/zexbox/open_telemetry.ex new file mode 100644 index 0000000..0cc851a --- /dev/null +++ b/lib/zexbox/open_telemetry.ex @@ -0,0 +1,136 @@ +defmodule Zexbox.OpenTelemetry do + @moduledoc """ + OpenTelemetry URL helpers for enriching Jira tickets with observability links. + + Mirrors `Opsbox::OpenTelemetry`. Reads from the current process's OTEL context + via `opentelemetry_api`. + + All functions return `nil` gracefully when OTEL is unconfigured or there is no + active span, so they are safe to call from any rescue block. + + ## Configuration + + ```elixir + config :zexbox, + service_name: "my-app", + app_env: :production, # :production | :sandbox | :dev | :test + tempo_datasource_uid: nil # optional override for the Grafana Tempo datasource UID + ``` + """ + + @production_tempo_uid "een1tos42jnk0d" + @sandbox_tempo_uid "eehwibr22b6kgb" + @grafana_host "zappi.grafana.net" + @kibana_host "zappi.tools" + + @doc """ + Returns a Grafana Tempo trace URL for the current span, or `nil` if no active span + or if the environment is not `:production` or `:sandbox`. + """ + @spec generate_trace_url() :: String.t() | nil + def generate_trace_url do + build_trace_url(get_span_context()) + rescue + _e -> nil + end + + @doc """ + Returns a Kibana Discover URL for `:production` and `:sandbox` environments, `nil` otherwise. + Includes the current trace ID in the query when an active span is present. + """ + @spec kibana_log_url() :: String.t() | nil + def kibana_log_url do + build_kibana_url(get_span_context()) + rescue + _e -> nil + end + + # --- Private --- + + defp build_trace_url(nil), do: nil + + defp build_trace_url(context) do + if app_env() in [:production, :sandbox] && :otel_span.is_valid(context) do + trace_id = hex_trace_id(context) + pane_json = build_pane_json(trace_id) + "https://#{@grafana_host}/explore?schemaVersion=1&panes=#{Jason.encode!(pane_json)}" + end + end + + defp build_kibana_url(context) do + env = app_env() + + if env in [:production, :sandbox] do + service = Application.get_env(:zexbox, :service_name, "app") + app_encoded = URI.encode(service, &URI.char_unreserved?/1) + trace_part = trace_filter(context) + + "https://kibana.#{kibana_subdomain(env)}#{@kibana_host}/app/discover#/?_a=(columns:!(log.message),filters:!()," <> + "query:(language:kuery,query:'zappi.app:%20%22#{app_encoded}%22#{trace_part}')," <> + "sort:!(!('@timestamp',asc)))&_g=(filters:!(),time:(from:now-1d,to:now))" + end + end + + defp kibana_subdomain(:production), do: "" + defp kibana_subdomain(env), do: "#{env}." + + defp trace_filter(nil), do: "" + + defp trace_filter(context) do + if :otel_span.is_valid(context), + do: "%20AND%20%22#{hex_trace_id(context)}%22", + else: "" + end + + defp get_span_context do + case :otel_tracer.current_span_ctx() do + :undefined -> nil + context -> context + end + rescue + _e -> nil + catch + _kind, _e -> nil + end + + defp hex_trace_id(context) do + context + |> :otel_span.trace_id() + |> Integer.to_string(16) + |> String.pad_leading(32, "0") + |> String.downcase() + end + + defp build_pane_json(trace_id) do + uid = tempo_datasource_uid() + + %{ + plo: %{ + datasource: uid, + queries: [ + %{ + refId: "A", + datasource: %{type: "tempo", uid: uid}, + queryType: "traceql", + limit: 20, + tableType: "traces", + metricsQueryType: "range", + query: trace_id + } + ], + range: %{from: "now-1h", to: "now"} + } + } + end + + defp tempo_datasource_uid do + Application.get_env(:zexbox, :tempo_datasource_uid) || + case app_env() do + :production -> @production_tempo_uid + :sandbox -> @sandbox_tempo_uid + _env -> nil + end + end + + defp app_env, do: Application.get_env(:zexbox, :app_env, :test) +end diff --git a/mix.exs b/mix.exs index dcc9968..74a2d22 100644 --- a/mix.exs +++ b/mix.exs @@ -41,9 +41,6 @@ defmodule Zexbox.MixProject do {:ldclient, "~> 3.8.0", hex: :launchdarkly_server_sdk}, {:mix_audit, "~> 2.0", only: [:dev, :test], runtime: false}, {:mock, "~> 0.3.0", only: :test}, - {:ldclient, "~> 3.8.0", hex: :launchdarkly_server_sdk}, - {:mix_audit, "~> 2.0", only: [:dev, :test], runtime: false}, - {:mock, "~> 0.3.0", only: :test}, {:opentelemetry_api, "~> 1.4"}, {:req, "~> 0.5"}, {:sobelow, "~> 0.8", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index c7e589d..079c4b4 100644 --- a/mix.lock +++ b/mix.lock @@ -36,6 +36,7 @@ "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"}, diff --git a/test/zexbox/auto_escalation/adf_builder_test.exs b/test/zexbox/auto_escalation/adf_builder_test.exs index e74d7e4..7e35c5a 100644 --- a/test/zexbox/auto_escalation/adf_builder_test.exs +++ b/test/zexbox/auto_escalation/adf_builder_test.exs @@ -75,7 +75,7 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "formats provided stacktrace in the expand block" do with_all_urls(fn -> stacktrace = [{MyModule, :my_fn, 2, [file: ~c"lib/my_module.ex", line: 42]}] - result = AdfBuilder.build_description(runtime_error(), %{}, %{}, stacktrace: stacktrace) + result = AdfBuilder.build_description(runtime_error(), %{}, %{}, stacktrace) json = Jason.encode!(result) assert json =~ "Stack trace" assert json =~ "my_module.ex" @@ -85,8 +85,8 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "includes custom_description paragraphs above Error Details" do with_all_urls(fn -> result = - AdfBuilder.build_description(runtime_error(), %{}, %{}, - custom_description: "This happened during sync." + AdfBuilder.build_description(runtime_error(), %{}, %{}, nil, + "This happened during sync." ) json = Jason.encode!(result) @@ -102,8 +102,8 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "splits custom_description on double newlines into multiple paragraphs" do with_all_urls(fn -> result = - AdfBuilder.build_description(runtime_error(), %{}, %{}, - custom_description: "First.\n\nSecond." + AdfBuilder.build_description(runtime_error(), %{}, %{}, nil, + "First.\n\nSecond." ) json = Jason.encode!(result) @@ -115,8 +115,8 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "adds a divider before custom_description when present" do with_all_urls(fn -> result = - AdfBuilder.build_description(runtime_error(), %{}, %{}, - custom_description: "Some context." + AdfBuilder.build_description(runtime_error(), %{}, %{}, nil, + "Some context." ) json = Jason.encode!(result) From 1093029cfd2d0badad0dea39a54bbb44718285a7 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 10 Mar 2026 11:15:26 +0000 Subject: [PATCH 29/31] style: Run mix format Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/auto_escalation/adf_builder.ex | 17 +++++++++++++++-- .../zexbox/auto_escalation/adf_builder_test.exs | 14 +++++++------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/lib/zexbox/auto_escalation/adf_builder.ex b/lib/zexbox/auto_escalation/adf_builder.ex index d8a9afd..8a67afd 100644 --- a/lib/zexbox/auto_escalation/adf_builder.ex +++ b/lib/zexbox/auto_escalation/adf_builder.ex @@ -30,7 +30,13 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do Exception.stacktrace() | nil, String.t() | nil ) :: map() - def build_description(exception, user_context, additional_context, stacktrace \\ nil, custom_description \\ nil) do + def build_description( + exception, + user_context, + additional_context, + stacktrace \\ nil, + custom_description \\ nil + ) do content = [telemetry_paragraph()] ++ optional_description_blocks(custom_description) ++ @@ -60,7 +66,14 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do Exception.stacktrace() | nil, String.t() | nil ) :: map() - def build_comment(exception, action, user_context, additional_context, stacktrace \\ nil, custom_description \\ nil) do + def build_comment( + exception, + action, + user_context, + additional_context, + stacktrace \\ nil, + custom_description \\ nil + ) do content = [heading(2, "Additional Occurrence (#{action})"), telemetry_paragraph()] ++ optional_description_blocks(custom_description) ++ diff --git a/test/zexbox/auto_escalation/adf_builder_test.exs b/test/zexbox/auto_escalation/adf_builder_test.exs index 7e35c5a..7908236 100644 --- a/test/zexbox/auto_escalation/adf_builder_test.exs +++ b/test/zexbox/auto_escalation/adf_builder_test.exs @@ -85,7 +85,11 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "includes custom_description paragraphs above Error Details" do with_all_urls(fn -> result = - AdfBuilder.build_description(runtime_error(), %{}, %{}, nil, + AdfBuilder.build_description( + runtime_error(), + %{}, + %{}, + nil, "This happened during sync." ) @@ -102,9 +106,7 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "splits custom_description on double newlines into multiple paragraphs" do with_all_urls(fn -> result = - AdfBuilder.build_description(runtime_error(), %{}, %{}, nil, - "First.\n\nSecond." - ) + AdfBuilder.build_description(runtime_error(), %{}, %{}, nil, "First.\n\nSecond.") json = Jason.encode!(result) assert json =~ "First." @@ -115,9 +117,7 @@ defmodule Zexbox.AutoEscalation.AdfBuilderTest do test "adds a divider before custom_description when present" do with_all_urls(fn -> result = - AdfBuilder.build_description(runtime_error(), %{}, %{}, nil, - "Some context." - ) + AdfBuilder.build_description(runtime_error(), %{}, %{}, nil, "Some context.") json = Jason.encode!(result) assert json =~ ~s("type":"rule") From c18cbe7f5fb92b519ea2e868f1ae0d97e7ff8bc7 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 10 Mar 2026 11:47:01 +0000 Subject: [PATCH 30/31] refactor: Delegate AdfBuilder content assembly to append_* pipeline Replace the ++ chain in build_description and build_comment with a shared private build_body/6 pipeline. Each section is now appended via a dedicated append_telemetry/1, append_description/2, append_context/3, and append_error_details/3 function that takes and returns the accumulator list, making the document structure readable as a left-to-right pipe and keeping each optional section's emptiness logic self-contained. Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/auto_escalation/adf_builder.ex | 139 ++++++++++++---------- 1 file changed, 73 insertions(+), 66 deletions(-) diff --git a/lib/zexbox/auto_escalation/adf_builder.ex b/lib/zexbox/auto_escalation/adf_builder.ex index 8a67afd..2bc6c17 100644 --- a/lib/zexbox/auto_escalation/adf_builder.ex +++ b/lib/zexbox/auto_escalation/adf_builder.ex @@ -37,14 +37,9 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do stacktrace \\ nil, custom_description \\ nil ) do - content = - [telemetry_paragraph()] ++ - optional_description_blocks(custom_description) ++ - context_blocks(user_context, additional_context) ++ - [heading(3, "Error Details")] ++ - error_details_blocks(exception, stacktrace) - - doc(content) + [] + |> build_body(exception, user_context, additional_context, stacktrace, custom_description) + |> doc() end @doc """ @@ -74,21 +69,29 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do stacktrace \\ nil, custom_description \\ nil ) do - content = - [heading(2, "Additional Occurrence (#{action})"), telemetry_paragraph()] ++ - optional_description_blocks(custom_description) ++ - context_blocks(user_context, additional_context) ++ - [heading(3, "Error Details")] ++ - error_details_blocks(exception, stacktrace) - - doc(content) + [heading(2, "Additional Occurrence (#{action})")] + |> build_body(exception, user_context, additional_context, stacktrace, custom_description) + |> doc() end # --- Private --- - defp doc(content), do: %{version: 1, type: "doc", content: content} + defp build_body( + acc, + exception, + user_context, + additional_context, + stacktrace, + custom_description + ) do + acc + |> append_telemetry() + |> append_description(custom_description) + |> append_context(user_context, additional_context) + |> append_error_details(exception, stacktrace) + end - defp telemetry_paragraph do + defp append_telemetry(acc) do trace_url = OpenTelemetry.generate_trace_url() kibana_url = OpenTelemetry.kibana_log_url() @@ -97,49 +100,33 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do [text(" | ")] ++ link_or_plain("Kibana Logs", kibana_url) - %{type: "paragraph", content: inline} - end - - defp link_or_plain(label, url) when is_binary(url) and url != "" do - [%{type: "text", text: label, marks: [%{type: "link", attrs: %{href: url}}]}] - end - - defp link_or_plain(label, _url) do - [%{type: "text", text: "#{label} (Missing)"}] + acc ++ [%{type: "paragraph", content: inline}] end - defp text(str), do: %{type: "text", text: str} - defp bold(str), do: %{type: "text", text: to_string(str), marks: [%{type: "strong"}]} - defp divider, do: %{type: "rule"} - - defp heading(level, text_content), - do: %{type: "heading", attrs: %{level: level}, content: [text(text_content)]} - - defp code_block(content), - do: %{type: "codeBlock", content: [%{type: "text", text: to_string(content)}]} - - defp expand(title, content_blocks), - do: %{type: "expand", attrs: %{title: title}, content: content_blocks} + defp append_description(acc, nil), do: acc + defp append_description(acc, ""), do: acc - defp optional_description_blocks(nil), do: [] - defp optional_description_blocks(""), do: [] - - defp optional_description_blocks(desc) do + defp append_description(acc, desc) do case String.trim(desc) do - "" -> [] - trimmed -> [divider() | custom_description_blocks(trimmed)] + "" -> acc + trimmed -> acc ++ [divider() | custom_description_blocks(trimmed)] end end - defp custom_description_blocks(desc) do - desc - |> String.split(~r/\n\n+/) - |> Enum.map(fn paragraph -> - %{type: "paragraph", content: [text(String.trim(paragraph))]} - end) + defp append_context(acc, user_context, additional_context) do + acc + |> append_single_context("User Context", user_context) + |> append_single_context("Additional Context", additional_context) + end + + defp append_single_context(acc, _label, ctx) when not is_map(ctx), do: acc + defp append_single_context(acc, _label, ctx) when map_size(ctx) == 0, do: acc + + defp append_single_context(acc, label, ctx) do + acc ++ [%{type: "paragraph", content: [bold(label)]}, key_value_bullet_list(ctx)] end - defp error_details_blocks(exception, stacktrace) do + defp append_error_details(acc, exception, stacktrace) do error_class = inspect(exception.__struct__) message = Exception.message(exception) summary = "#{error_class}: #{message}" @@ -151,22 +138,20 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do st -> Exception.format_stacktrace(st) end - [ - %{type: "paragraph", content: [text(summary)]}, - expand("Stack trace", [code_block(backtrace)]) - ] + acc ++ + [ + heading(3, "Error Details"), + %{type: "paragraph", content: [text(summary)]}, + expand("Stack trace", [code_block(backtrace)]) + ] end - defp context_blocks(user_context, additional_context) do - single_context_blocks("User Context", user_context) ++ - single_context_blocks("Additional Context", additional_context) - end - - defp single_context_blocks(_label, ctx) when not is_map(ctx), do: [] - defp single_context_blocks(_label, ctx) when map_size(ctx) == 0, do: [] - - defp single_context_blocks(label, ctx) do - [%{type: "paragraph", content: [bold(label)]}, key_value_bullet_list(ctx)] + defp custom_description_blocks(desc) do + desc + |> String.split(~r/\n\n+/) + |> Enum.map(fn paragraph -> + %{type: "paragraph", content: [text(String.trim(paragraph))]} + end) end defp key_value_bullet_list(hash) do @@ -185,4 +170,26 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do %{type: "bulletList", content: items} end + + defp doc(content), do: %{version: 1, type: "doc", content: content} + defp text(str), do: %{type: "text", text: str} + defp bold(str), do: %{type: "text", text: to_string(str), marks: [%{type: "strong"}]} + defp divider, do: %{type: "rule"} + + defp heading(level, text_content), + do: %{type: "heading", attrs: %{level: level}, content: [text(text_content)]} + + defp code_block(content), + do: %{type: "codeBlock", content: [%{type: "text", text: to_string(content)}]} + + defp expand(title, content_blocks), + do: %{type: "expand", attrs: %{title: title}, content: content_blocks} + + defp link_or_plain(label, url) when is_binary(url) and url != "" do + [%{type: "text", text: label, marks: [%{type: "link", attrs: %{href: url}}]}] + end + + defp link_or_plain(label, _url) do + [%{type: "text", text: "#{label} (Missing)"}] + end end From cbe32337cf2af41d3a09ac9f0b6ede48fa580ff0 Mon Sep 17 00:00:00 2001 From: Jez-A Date: Tue, 10 Mar 2026 11:49:54 +0000 Subject: [PATCH 31/31] refactor: Rename ctx to context in append_single_context for consistency Co-Authored-By: Claude Sonnet 4.6 --- lib/zexbox/auto_escalation/adf_builder.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/zexbox/auto_escalation/adf_builder.ex b/lib/zexbox/auto_escalation/adf_builder.ex index 2bc6c17..a05b155 100644 --- a/lib/zexbox/auto_escalation/adf_builder.ex +++ b/lib/zexbox/auto_escalation/adf_builder.ex @@ -119,11 +119,11 @@ defmodule Zexbox.AutoEscalation.AdfBuilder do |> append_single_context("Additional Context", additional_context) end - defp append_single_context(acc, _label, ctx) when not is_map(ctx), do: acc - defp append_single_context(acc, _label, ctx) when map_size(ctx) == 0, do: acc + defp append_single_context(acc, _label, context) when not is_map(context), do: acc + defp append_single_context(acc, _label, context) when map_size(context) == 0, do: acc - defp append_single_context(acc, label, ctx) do - acc ++ [%{type: "paragraph", content: [bold(label)]}, key_value_bullet_list(ctx)] + defp append_single_context(acc, label, context) do + acc ++ [%{type: "paragraph", content: [bold(label)]}, key_value_bullet_list(context)] end defp append_error_details(acc, exception, stacktrace) do