Skip to content

Commit 761cacb

Browse files
authored
feat: Add Zexbox.JiraClient (#53)
* feat: Add Zexbox.JiraClient to link error handling with support processes.
1 parent 5164578 commit 761cacb

5 files changed

Lines changed: 392 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## 1.5.2 - 2026-03-09
9+
10+
- Added the JiraClient, allowing us to create, search, comment on Jira tickets for support processes.
11+
812
## 1.5.1 - 2026-02-05
913

1014
- Handles cases where `$callers` and `$ancestors` may not be pids to avoid crashing metric handler.

lib/zexbox/jira_client.ex

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
defmodule Zexbox.JiraClient do
2+
@moduledoc """
3+
HTTP client for the Jira Cloud REST API v3.
4+
5+
Mirrors `Opsbox::JiraClient`. Authenticates with Basic auth using
6+
`JIRA_USER_EMAIL_ADDRESS` and `JIRA_API_TOKEN` environment variables
7+
(or `:jira_email` / `:jira_api_token` application config).
8+
9+
## Configuration
10+
11+
```elixir
12+
config :zexbox,
13+
jira_base_url: "https://your-org.atlassian.net",
14+
jira_email: System.get_env("JIRA_USER_EMAIL_ADDRESS"),
15+
jira_api_token: System.get_env("JIRA_API_TOKEN")
16+
```
17+
18+
All public functions return `{:ok, result}` or `{:error, reason}`.
19+
"""
20+
21+
@bug_fingerprint_field %{id: "customfield_13442", name: "Bug Fingerprint[Short text]"}
22+
@zigl_team_field %{id: "customfield_10101", name: "ZIGL Team[Dropdown]"}
23+
24+
@doc "Returns the bug fingerprint custom field metadata."
25+
@spec bug_fingerprint_field() :: %{id: String.t(), name: String.t()}
26+
def bug_fingerprint_field, do: @bug_fingerprint_field
27+
28+
@doc "Returns the ZIGL team custom field metadata."
29+
@spec zigl_team_field() :: %{id: String.t(), name: String.t()}
30+
def zigl_team_field, do: @zigl_team_field
31+
32+
@doc """
33+
Search for the latest issues matching a JQL query (max 50 results).
34+
35+
- `jql` – JQL query string.
36+
- `project_key` – optional; prepends `project = KEY AND` to the JQL.
37+
38+
Returns `{:ok, [issue_map]}` where each map includes a `"url"` browse key,
39+
or `{:error, reason}` on failure.
40+
"""
41+
@spec search_latest_issues(String.t(), String.t() | nil) :: {:ok, [map()]} | {:error, term()}
42+
def search_latest_issues(jql, project_key \\ nil) do
43+
fetch_issues(build_query(jql, project_key))
44+
end
45+
46+
@doc """
47+
Create a new Jira issue.
48+
49+
- `project_key` – Jira project key (e.g. `"SS"`).
50+
- `summary` – issue summary string.
51+
- `description` – ADF map (already built; not converted).
52+
- `issuetype` – issue type name (e.g. `"Bug"`).
53+
- `priority` – priority name (e.g. `"High"`).
54+
- `custom_fields` – optional map of custom field ID → value (string keys).
55+
56+
Returns `{:ok, issue_map}` with a `"url"` browse key added, or `{:error, reason}`.
57+
"""
58+
@spec create_issue(String.t(), String.t(), map(), String.t(), String.t(), map()) ::
59+
{:ok, map()} | {:error, term()}
60+
def create_issue(project_key, summary, description, issuetype, priority, custom_fields \\ %{}) do
61+
fields =
62+
Map.merge(
63+
%{
64+
"project" => %{"key" => project_key},
65+
"summary" => summary,
66+
"description" => description,
67+
"issuetype" => %{"name" => issuetype},
68+
"priority" => %{"name" => priority}
69+
},
70+
custom_fields
71+
)
72+
73+
post_issue(fields)
74+
end
75+
76+
@doc """
77+
Transition a Jira issue to a new status by name (case-insensitive match).
78+
79+
- `issue_key` – issue key (e.g. `"SS-42"`).
80+
- `status_name` – target status name (e.g. `"To do"`).
81+
82+
Returns `{:ok, %{success: true, status: name}}` or `{:error, reason}`.
83+
"""
84+
@spec transition_issue(String.t(), String.t()) :: {:ok, map()} | {:error, term()}
85+
def transition_issue(issue_key, status_name) do
86+
client = build_client()
87+
88+
with {:ok, data} <- jira_get(client, "/rest/api/3/issue/#{issue_key}/transitions"),
89+
transitions = Map.get(data, "transitions", []),
90+
{:ok, target} <- find_transition(transitions, status_name),
91+
{:ok, _resp} <-
92+
jira_post(client, "/rest/api/3/issue/#{issue_key}/transitions", %{
93+
"transition" => %{"id" => target["id"]}
94+
}) do
95+
{:ok, %{success: true, status: get_in(target, ["to", "name"])}}
96+
end
97+
end
98+
99+
@doc """
100+
Add a comment to an existing Jira issue.
101+
102+
- `issue_key` – issue key (e.g. `"SS-42"`).
103+
- `comment` – ADF map for the comment body (already built; not converted).
104+
105+
Returns `{:ok, comment_map}` or `{:error, reason}`.
106+
"""
107+
@spec add_comment(String.t(), map()) :: {:ok, map()} | {:error, term()}
108+
def add_comment(issue_key, comment), do: post_comment(issue_key, comment)
109+
110+
# --- Private ---
111+
112+
defp build_query(jql, nil), do: jql
113+
defp build_query(jql, project_key), do: "project = #{project_key} AND #{jql}"
114+
115+
defp fetch_issues(query) do
116+
build_client()
117+
|> jira_get("/rest/api/3/issue/search",
118+
jql: query,
119+
maxResults: 50,
120+
fields: ["key", "id", "self", "status", "summary"]
121+
)
122+
|> attach_issue_urls()
123+
end
124+
125+
defp post_issue(fields) do
126+
build_client()
127+
|> jira_post("/rest/api/3/issue", %{"fields" => fields})
128+
|> attach_issue_url()
129+
end
130+
131+
defp post_comment(issue_key, comment) do
132+
build_client()
133+
|> jira_post("/rest/api/3/issue/#{issue_key}/comment", %{"body" => comment})
134+
end
135+
136+
defp attach_issue_urls({:ok, body}) do
137+
issues = Map.get(body, "issues", [])
138+
{:ok, Enum.map(issues, &Map.put(&1, "url", browse_url(&1["key"])))}
139+
end
140+
141+
defp attach_issue_urls({:error, _reason} = err), do: err
142+
143+
defp attach_issue_url({:ok, result}),
144+
do: {:ok, Map.put(result, "url", browse_url(result["key"]))}
145+
146+
defp attach_issue_url({:error, _reason} = err), do: err
147+
148+
defp browse_url(key), do: "#{config(:jira_base_url, nil)}/browse/#{key}"
149+
150+
defp find_transition(transitions, status_name) do
151+
case Enum.find(transitions, &matches_status?(&1, status_name)) do
152+
nil -> {:error, "Cannot transition to '#{status_name}'"}
153+
target -> {:ok, target}
154+
end
155+
end
156+
157+
defp matches_status?(transition, status_name) do
158+
to_name = get_in(transition, ["to", "name"]) |> to_string()
159+
String.downcase(to_name) == String.downcase(status_name)
160+
end
161+
162+
defp jira_get(client, path, params \\ []) do
163+
Req.get(client, url: path, params: params)
164+
|> handle_response()
165+
end
166+
167+
defp jira_post(client, path, body) do
168+
Req.post(client, url: path, json: body)
169+
|> handle_response()
170+
end
171+
172+
defp handle_response({:ok, %{status: status, body: body}}) when status in 200..299,
173+
do: {:ok, body || %{}}
174+
175+
defp handle_response({:ok, %{status: status, body: body}}),
176+
do: {:error, "HTTP #{status}: #{inspect(body)}"}
177+
178+
defp handle_response({:error, reason}),
179+
do: {:error, inspect(reason)}
180+
181+
defp build_client do
182+
email = config(:jira_email, System.get_env("JIRA_USER_EMAIL_ADDRESS", ""))
183+
token = config(:jira_api_token, System.get_env("JIRA_API_TOKEN", ""))
184+
185+
Req.new(
186+
base_url: config(:jira_base_url, nil),
187+
auth: {:basic, "#{email}:#{token}"},
188+
headers: [{"accept", "application/json"}]
189+
)
190+
end
191+
192+
defp config(key, default), do: Application.get_env(:zexbox, key, default)
193+
end

mix.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule Zexbox.MixProject do
44
def project do
55
[
66
app: :zexbox,
7-
version: "1.5.1",
7+
version: "1.5.2",
88
elixir: "~> 1.14",
99
start_permanent: Mix.env() == :prod,
1010
dialyzer: [plt_add_apps: [:mix, :ex_unit]],
@@ -40,6 +40,7 @@ defmodule Zexbox.MixProject do
4040
{:ldclient, "~> 3.8.0", hex: :launchdarkly_server_sdk},
4141
{:mix_audit, "~> 2.0", only: [:dev, :test], runtime: false},
4242
{:mock, "~> 0.3.0", only: :test},
43+
{:req, "~> 0.5"},
4344
{:sobelow, "~> 0.8", only: [:dev, :test]},
4445
{:telemetry, "~> 1.3"}
4546
]

mix.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
"erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
1212
"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"},
1313
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
14+
"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"},
1415
"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"},
1516
"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"},
17+
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
1618
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
1719
"influxql": {:hex, :influxql, "0.2.1", "71bfd5c0d81bf870f239baf3357bf5226b44fce16e1b9399ba1368203ca71245", [:mix], [], "hexpm", "75faf04960d6830ca0827869eaac1ba092655041c5e96deb2a588bafb601205c"},
1820
"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 @@
2527
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
2628
"meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"},
2729
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
30+
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
2831
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
32+
"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"},
2933
"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"},
3034
"mock": {:hex, :mock, "0.3.9", "10e44ad1f5962480c5c9b9fa779c6c63de9bd31997c8e04a853ec990a9d841af", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "9e1b244c4ca2551bb17bb8415eed89e40ee1308e0fbaed0a4fdfe3ec8a4adbd3"},
3135
"nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"},
36+
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
3237
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
38+
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
3339
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
3440
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
3541
"quickrand": {:hex, :quickrand, "2.0.7", "d2bd76676a446e6a058d678444b7fda1387b813710d1af6d6e29bb92186c8820", [:rebar3], [], "hexpm", "b8acbf89a224bc217c3070ca8bebc6eb236dbe7f9767993b274084ea044d35f0"},
42+
"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"},
3643
"shotgun": {:hex, :shotgun, "1.2.1", "a720063b49a763a97b245cc1ab6ee34e0e50d1ef61858e080db8e3b0dcd31af2", [:rebar3], [{:gun, "2.2.0", [hex: :gun, repo: "hexpm", optional: false]}], "hexpm", "a5ed7a1ff851419a70e292c4e2649c4d2c633141eb9a3432a4896c72b6d3f212"},
3744
"sobelow": {:hex, :sobelow, "0.14.0", "dd82aae8f72503f924fe9dd97ffe4ca694d2f17ec463dcfd365987c9752af6ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7ecf91e298acfd9b24f5d761f19e8f6e6ac585b9387fb6301023f1f2cd5eed5f"},
3845
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},

0 commit comments

Comments
 (0)