Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
193 changes: 193 additions & 0 deletions lib/zexbox/jira_client.ex
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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]],
Expand Down Expand Up @@ -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"}
]
Expand Down
7 changes: 7 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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"},
Expand Down
Loading