Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
188 changes: 188 additions & 0 deletions lib/zexbox/jira_client.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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
query = if project_key, do: "project = #{project_key} AND #{jql}", else: jql

client = build_client()

case jira_get(client, "/rest/api/3/issue/search",
Comment thread
Jez-A marked this conversation as resolved.
Outdated
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, _reason} = err ->
err
end
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
)

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, _reason} = err ->
err
end
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", %{
Comment thread
Jez-A marked this conversation as resolved.
"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
client = build_client()
jira_post(client, "/rest/api/3/issue/#{issue_key}/comment", %{"body" => comment})
end

defp find_transition(transitions, status_name) do
case Enum.find(transitions, fn t ->
Comment thread
Jez-A marked this conversation as resolved.
Outdated
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}"},
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