From 5dced33ec7f808e18575d5a26ae659f410200431 Mon Sep 17 00:00:00 2001 From: Alex Kotliarskyi Date: Fri, 17 Jul 2026 22:58:27 -0700 Subject: [PATCH 1/2] feat(tracker): add Jira Cloud adapter Summary: - Add Jira Cloud enhanced-search polling, bulk ID refresh, and ADF normalization behind the generic tracker read boundary. - Expose host-authenticated jira_rest while stripping declared token environments from Codex children. - Add contract coverage, a real opt-in Jira E2E, and adapter docs. Rationale: - Keep Jira workflow names provider-native while preserving a small scheduler-facing read contract. - Preserve useful ADF text without inventing generic tracker writes or tenant-specific priority and blocker policy. Tests: - MIX_DEPS_PATH=/Users/frantic/code/symphony/elixir/deps make all - SYMPHONY_RUN_JIRA_LIVE_E2E=1 mix test test/symphony_elixir/jira_live_e2e_test.exs - SYMPHONY_RUN_LIVE_E2E=1 mix test test/symphony_elixir/live_e2e_test.exs:123 Co-authored-by: Codex --- elixir/README.md | 55 +- elixir/lib/symphony_elixir/jira/adapter.ex | 50 ++ elixir/lib/symphony_elixir/jira/agent_tool.ex | 174 ++++++ elixir/lib/symphony_elixir/jira/client.ex | 459 +++++++++++++++ elixir/lib/symphony_elixir/tracker.ex | 1 + elixir/mix.exs | 1 + .../test/symphony_elixir/extensions_test.exs | 4 +- .../symphony_elixir/jira_adapter_test.exs | 488 ++++++++++++++++ .../symphony_elixir/jira_live_e2e_test.exs | 533 ++++++++++++++++++ 9 files changed, 1759 insertions(+), 6 deletions(-) create mode 100644 elixir/lib/symphony_elixir/jira/adapter.ex create mode 100644 elixir/lib/symphony_elixir/jira/agent_tool.ex create mode 100644 elixir/lib/symphony_elixir/jira/client.ex create mode 100644 elixir/test/symphony_elixir/jira_adapter_test.exs create mode 100644 elixir/test/symphony_elixir/jira_live_e2e_test.exs diff --git a/elixir/README.md b/elixir/README.md index 25f5411874..1a07a2c5ac 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -13,7 +13,7 @@ This directory contains the current Elixir/OTP implementation of Symphony, based ## How it works -1. Polls the configured tracker for candidate work (the included production adapter is Linear) +1. Polls the configured tracker for candidate work (included adapters: Linear and Jira Cloud) 2. Creates a workspace per issue 3. Launches Codex in [App Server mode](https://developers.openai.com/codex/app-server/) inside the workspace @@ -21,9 +21,9 @@ This directory contains the current Elixir/OTP implementation of Symphony, based 5. Keeps Codex working on the issue until the work is done During app-server sessions, the selected tracker adapter may advertise provider-native tools. The -included Linear adapter serves `linear_graphql` so repo skills can make raw Linear GraphQL calls. -Symphony executes that tool with its configured auth and removes `LINEAR_API_KEY` from the Codex -child environment, so the agent does not need a second tracker login. +Linear adapter serves `linear_graphql`; the Jira Cloud adapter serves `jira_rest`. Symphony executes +those tools with configured host-side auth and removes declared tracker-token environment variables +from the Codex child, so the agent does not need a second tracker login. If a claimed issue moves to a terminal state (`Done`, `Closed`, `Cancelled`, or `Duplicate`), Symphony stops the active agent for that issue and cleans up matching workspaces. @@ -238,6 +238,37 @@ codex: `tracker_payload`, and missing cursors to `tracker_pagination`; logs and tool responses carry the human-readable provider detail. +### Jira Cloud adapter profile + +- Config: use `tracker.kind: jira` with `tracker.provider.base_url` (defaults to + `JIRA_BASE_URL` and accepts `$VAR`), `email` (defaults to `JIRA_EMAIL`), `api_token` + (defaults to `JIRA_API_TOKEN` and accepts `$VAR`), and required `project_key`. + Set explicit provider-native `active_states` and `terminal_states`; Jira workflows are + tenant-defined, so Symphony does not invent status defaults. +- Scope and paging: candidate reads use Jira enhanced search with project/status JQL and follow + opaque `nextPageToken` pages of 100. ID refreshes use `issue/bulkfetch` in batches of 100 and + omit missing or out-of-project issues. Empty state/ID lists return `{:ok, []}` without a Jira + request. +- Identity and normalization: `issue.id` is Jira's immutable issue ID and `issue.identifier` is + the issue key. State keeps Jira's status spelling; ADF descriptions are projected to plain text, + labels are trimmed/lowercased/deduplicated, assignee account IDs are preserved, and Jira's + `+0000` timestamp offsets are parsed. The adapter leaves priority and blockers unset because + those semantics are tenant-specific. +- Tool: the adapter advertises `jira_rest`, accepting `method`, a relative path beginning with + `/rest/api/3/`, optional object `query`, and optional JSON `body`. Symphony executes it host-side + with Basic auth and strips `JIRA_API_TOKEN` plus any configured `$VAR` token name from the Codex + child. Scheduler reads are project-scoped; the raw tool can access whatever the configured Jira + credential can access. +- Responsibility and errors: `jira_rest` adds no idempotency key, retry, scope guard, or + rate-limit policy. It preserves REST `status` and `body` in the tool result, with non-2xx + responses marked `"success" => false`. Read/config failures use + `{:error, :missing_jira_active_states}`, `{:error, :missing_jira_terminal_states}`, + `{:error, :invalid_jira_states}`, `{:error, :invalid_jira_base_url}`, + `{:error, :missing_jira_email}`, `{:error, :missing_jira_api_token}`, + `{:error, :missing_jira_project_key}`, `{:error, {:jira_api_status, status}}`, + `{:error, {:jira_api_request, reason}}`, `{:error, :jira_unknown_payload}`, or + `{:error, :jira_missing_next_page_token}`. + ## Web dashboard The observability UI now runs on a minimal Phoenix stack: @@ -291,6 +322,22 @@ The live test creates a temporary Linear project and issue, writes a temporary ` a real agent turn, verifies the workspace side effect, requires Codex to comment on and close the Linear issue, then marks the project completed so the run remains visible in Linear. +Run the opt-in Jira Cloud live test with a disposable project whose credential can browse, create, +comment on, transition, and delete issues: + +```bash +cd elixir +export JIRA_BASE_URL=https://your-site.atlassian.net +export JIRA_EMAIL=... +export JIRA_API_TOKEN=... +export SYMPHONY_LIVE_JIRA_PROJECT_KEY=TEST +SYMPHONY_RUN_JIRA_LIVE_E2E=1 mix test test/symphony_elixir/jira_live_e2e_test.exs +``` + +It creates one disposable issue, verifies both tracker reads, runs a real local Codex worker, +requires `jira_rest` to post an exact comment and transition the issue to an available terminal +status, verifies the workspace file and direct Jira readback, then deletes the test issue. + ## FAQ ### Why Elixir? diff --git a/elixir/lib/symphony_elixir/jira/adapter.ex b/elixir/lib/symphony_elixir/jira/adapter.ex new file mode 100644 index 0000000000..a68ff3c28d --- /dev/null +++ b/elixir/lib/symphony_elixir/jira/adapter.ex @@ -0,0 +1,50 @@ +defmodule SymphonyElixir.Jira.Adapter do + @moduledoc """ + Jira Cloud-backed tracker adapter. + """ + + @behaviour SymphonyElixir.Tracker + + alias SymphonyElixir.Jira.{AgentTool, Client} + alias SymphonyElixir.Tracker.Issue + + @spec validate_config(map()) :: :ok | {:error, term()} + def validate_config(tracker_settings) do + with :ok <- validate_states(tracker_settings.active_states, :missing_jira_active_states), + :ok <- validate_states(tracker_settings.terminal_states, :missing_jira_terminal_states) do + Client.validate_settings(tracker_settings) + end + end + + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states(states), do: client_module().fetch_issues_by_states(states) + + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(ids), do: client_module().fetch_issues_by_ids(ids) + + @spec agent_tool_specs() :: [map()] + def agent_tool_specs, do: AgentTool.tool_specs() + + @spec execute_agent_tool(String.t(), term(), keyword()) :: map() + def execute_agent_tool(tool, arguments, opts), do: AgentTool.execute(tool, arguments, opts) + + @spec secret_environment_names(map()) :: [String.t()] + def secret_environment_names(tracker_settings), do: Client.secret_environment_names(tracker_settings) + + defp client_module do + Application.get_env(:symphony_elixir, :jira_client_module, Client) + end + + defp validate_states(states, _missing_error) when is_list(states) do + if Enum.all?(states, &present_string?/1) do + :ok + else + {:error, :invalid_jira_states} + end + end + + defp validate_states(_states, missing_error), do: {:error, missing_error} + + defp present_string?(value) when is_binary(value), do: String.trim(value) != "" + defp present_string?(_value), do: false +end diff --git a/elixir/lib/symphony_elixir/jira/agent_tool.ex b/elixir/lib/symphony_elixir/jira/agent_tool.ex new file mode 100644 index 0000000000..c33e0202c2 --- /dev/null +++ b/elixir/lib/symphony_elixir/jira/agent_tool.ex @@ -0,0 +1,174 @@ +defmodule SymphonyElixir.Jira.AgentTool do + @moduledoc """ + Provider-native Jira Cloud REST tool exposed to Codex app-server turns. + """ + + alias SymphonyElixir.Jira.Client + + @jira_rest_tool "jira_rest" + @allowed_methods ["GET", "POST", "PUT", "DELETE"] + @jira_rest_description """ + Execute a Jira Cloud REST v3 request using Symphony's configured auth. + """ + @jira_rest_input_schema %{ + "type" => "object", + "additionalProperties" => false, + "required" => ["method", "path"], + "properties" => %{ + "method" => %{ + "type" => "string", + "enum" => @allowed_methods, + "description" => "Jira REST method." + }, + "path" => %{ + "type" => "string", + "description" => "Jira REST v3 path beginning with /rest/api/3/." + }, + "query" => %{ + "type" => ["object", "null"], + "description" => "Optional query parameters.", + "additionalProperties" => true + }, + "body" => %{ + "description" => "Optional JSON request body." + } + } + } + + @spec execute(String.t() | nil, term(), keyword()) :: map() + def execute(tool, arguments, opts) do + case tool do + @jira_rest_tool -> execute_jira_rest(arguments, opts) + other -> unsupported_tool_response(other) + end + end + + @spec tool_specs() :: [map()] + def tool_specs do + [ + %{ + "name" => @jira_rest_tool, + "description" => @jira_rest_description, + "inputSchema" => @jira_rest_input_schema + } + ] + end + + defp execute_jira_rest(arguments, opts) do + jira_client = Keyword.get(opts, :jira_client, &Client.request/5) + client_opts = Keyword.take(opts, [:tracker_settings]) + + with {:ok, method, path, query, body} <- normalize_arguments(arguments), + {:ok, %{status: status, body: response_body}} <- + jira_client.(method, path, query, body, client_opts), + true <- is_integer(status) do + rest_response(status, response_body) + else + {:error, reason} -> failure_response(tool_error_payload(reason)) + _ -> failure_response(tool_error_payload(:jira_unknown_payload)) + end + end + + defp normalize_arguments(arguments) when is_map(arguments) do + with {:ok, method} <- normalize_method(Map.get(arguments, "method")), + {:ok, path} <- normalize_path(Map.get(arguments, "path")), + {:ok, query} <- normalize_query(Map.get(arguments, "query")) do + {:ok, method, path, query, Map.get(arguments, "body")} + end + end + + defp normalize_arguments(_arguments), do: {:error, :invalid_arguments} + + defp normalize_method(method) when is_binary(method) do + normalized = method |> String.trim() |> String.upcase() + if normalized in @allowed_methods, do: {:ok, normalized}, else: {:error, :invalid_method} + end + + defp normalize_method(_method), do: {:error, :invalid_method} + + defp normalize_path(path) when is_binary(path) do + trimmed = String.trim(path) + + if String.starts_with?(trimmed, "/rest/api/3/") and + not String.contains?(trimmed, ["://", "\n", "\r", <<0>>]) do + {:ok, trimmed} + else + {:error, :invalid_path} + end + end + + defp normalize_path(_path), do: {:error, :invalid_path} + + defp normalize_query(nil), do: {:ok, %{}} + defp normalize_query(query) when is_map(query), do: {:ok, query} + defp normalize_query(_query), do: {:error, :invalid_query} + + defp rest_response(status, body) do + dynamic_tool_response(status in 200..299, encode_payload(%{"status" => status, "body" => body})) + end + + defp failure_response(payload), do: dynamic_tool_response(false, encode_payload(payload)) + + defp dynamic_tool_response(success, output) do + %{ + "success" => success, + "output" => output, + "contentItems" => [%{"type" => "inputText", "text" => output}] + } + end + + defp encode_payload(payload) do + case Jason.encode(payload, pretty: true) do + {:ok, output} -> output + {:error, _reason} -> inspect(payload) + end + end + + defp unsupported_tool_response(tool) do + failure_response(%{ + "error" => %{ + "message" => "Unsupported dynamic tool: #{inspect(tool)}.", + "supportedTools" => supported_tool_names() + } + }) + end + + defp tool_error_payload(:invalid_arguments) do + %{"error" => %{"message" => "jira_rest expects an object with method and path."}} + end + + defp tool_error_payload(:invalid_method) do + %{"error" => %{"message" => "jira_rest.method must be GET, POST, PUT, or DELETE."}} + end + + defp tool_error_payload(:invalid_path) do + %{"error" => %{"message" => "jira_rest.path must begin with /rest/api/3/."}} + end + + defp tool_error_payload(:invalid_query) do + %{"error" => %{"message" => "jira_rest.query must be a JSON object when provided."}} + end + + defp tool_error_payload(:missing_jira_api_token) do + %{ + "error" => %{ + "message" => "Symphony is missing Jira auth. Set tracker.provider.api_token or export JIRA_API_TOKEN." + } + } + end + + defp tool_error_payload({:jira_api_request, reason}) do + %{ + "error" => %{ + "message" => "Jira API request failed before receiving a successful response.", + "reason" => inspect(reason) + } + } + end + + defp tool_error_payload(reason) do + %{"error" => %{"message" => "Jira REST tool execution failed.", "reason" => inspect(reason)}} + end + + defp supported_tool_names, do: Enum.map(tool_specs(), & &1["name"]) +end diff --git a/elixir/lib/symphony_elixir/jira/client.ex b/elixir/lib/symphony_elixir/jira/client.ex new file mode 100644 index 0000000000..7637761541 --- /dev/null +++ b/elixir/lib/symphony_elixir/jira/client.ex @@ -0,0 +1,459 @@ +defmodule SymphonyElixir.Jira.Client do + @moduledoc """ + Thin Jira Cloud REST client for project-scoped issue polling. + """ + + require Logger + alias SymphonyElixir.Config + alias SymphonyElixir.Tracker.Issue + + @page_size 100 + @issue_fields ["summary", "description", "status", "labels", "assignee", "created", "updated", "project"] + + @spec validate_settings(map()) :: :ok | {:error, term()} + def validate_settings(tracker_settings) do + with {:ok, _settings} <- settings(tracker_settings), do: :ok + end + + @spec secret_environment_names(map()) :: [String.t()] + def secret_environment_names(tracker_settings) do + provider = provider_settings(tracker_settings) + + ["JIRA_API_TOKEN" | env_reference_names([provider["api_token"]])] + |> Enum.uniq() + end + + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states(states) when is_list(states) do + fetch_issues_by_states(states, Config.settings!().tracker, &perform_request/5) + end + + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(ids) when is_list(ids) do + fetch_issues_by_ids(ids, Config.settings!().tracker, &perform_request/5) + end + + @spec request(String.t(), String.t(), map(), term(), keyword()) :: + {:ok, %{status: integer(), body: term()}} | {:error, term()} + def request(method, path, query, body, opts \\ []) + when is_binary(method) and is_binary(path) and is_map(query) and is_list(opts) do + tracker_settings = Keyword.get_lazy(opts, :tracker_settings, fn -> Config.settings!().tracker end) + request_fun = Keyword.get(opts, :request_fun, &perform_request/5) + + with {:ok, jira_settings} <- settings(tracker_settings) do + request_fun.(method, path, query, body, jira_settings) + end + end + + @doc false + @spec normalize_issue_for_test(map(), map()) :: Issue.t() | nil + def normalize_issue_for_test(issue, tracker_settings) + when is_map(issue) and is_map(tracker_settings) do + case settings(tracker_settings) do + {:ok, jira_settings} -> normalize_issue(issue, jira_settings) + _ -> nil + end + end + + @doc false + @spec fetch_issues_by_states_for_test([String.t()], map(), function()) :: + {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states_for_test(states, tracker_settings, request_fun) + when is_list(states) and is_map(tracker_settings) and is_function(request_fun, 5) do + fetch_issues_by_states(states, tracker_settings, request_fun) + end + + @doc false + @spec fetch_issues_by_ids_for_test([String.t()], map(), function()) :: + {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids_for_test(ids, tracker_settings, request_fun) + when is_list(ids) and is_map(tracker_settings) and is_function(request_fun, 5) do + fetch_issues_by_ids(ids, tracker_settings, request_fun) + end + + defp fetch_issues_by_states([], _tracker_settings, _request_fun), do: {:ok, []} + + defp fetch_issues_by_states(states, tracker_settings, request_fun) do + with {:ok, jira_settings} <- settings(tracker_settings) do + fetch_state_pages(states, jira_settings, nil, request_fun, []) + end + end + + defp fetch_state_pages(states, settings, next_page_token, request_fun, pages) do + body = + %{ + "jql" => state_jql(settings.project_key, states), + "fields" => @issue_fields, + "maxResults" => @page_size + } + |> maybe_put("nextPageToken", next_page_token) + + with {:ok, payload} <- + request_with_settings( + "POST", + "/rest/api/3/search/jql", + %{}, + body, + settings, + request_fun + ), + {:ok, raw_issues, next_token} <- state_page(payload) do + issues = normalize_candidate_page(raw_issues, settings, states) + updated_pages = [issues | pages] + + case next_token do + :done -> {:ok, updated_pages |> Enum.reverse() |> List.flatten()} + token -> fetch_state_pages(states, settings, token, request_fun, updated_pages) + end + end + end + + defp fetch_issues_by_ids([], _tracker_settings, _request_fun), do: {:ok, []} + + defp fetch_issues_by_ids(ids, tracker_settings, request_fun) do + with {:ok, jira_settings} <- settings(tracker_settings) do + ids + |> Enum.uniq() + |> Enum.chunk_every(@page_size) + |> fetch_id_batches(jira_settings, request_fun, []) + end + end + + defp fetch_id_batches([], _settings, _request_fun, pages) do + {:ok, pages |> Enum.reverse() |> List.flatten()} + end + + defp fetch_id_batches([ids | rest], settings, request_fun, pages) do + body = %{"issueIdsOrKeys" => ids, "fields" => @issue_fields} + + with {:ok, payload} <- + request_with_settings( + "POST", + "/rest/api/3/issue/bulkfetch", + %{}, + body, + settings, + request_fun + ), + {:ok, raw_issues} <- issues_payload(payload), + {:ok, issues} <- normalize_requested_issues(raw_issues, ids, settings) do + fetch_id_batches(rest, settings, request_fun, [issues | pages]) + end + end + + defp state_page(%{"issues" => issues, "isLast" => true}) when is_list(issues) do + {:ok, issues, :done} + end + + defp state_page(%{"issues" => issues, "isLast" => false, "nextPageToken" => token}) + when is_list(issues) and is_binary(token) and token != "" do + {:ok, issues, token} + end + + defp state_page(%{"issues" => issues, "isLast" => false}) when is_list(issues) do + {:error, :jira_missing_next_page_token} + end + + defp state_page(_payload), do: {:error, :jira_unknown_payload} + + defp issues_payload(%{"issues" => issues}) when is_list(issues), do: {:ok, issues} + defp issues_payload(_payload), do: {:error, :jira_unknown_payload} + + defp normalize_candidate_page(raw_issues, settings, states) do + requested_states = states |> Enum.map(&normalize_state/1) |> MapSet.new() + issues = Enum.map(raw_issues, &normalize_issue(&1, settings)) + malformed_count = Enum.count(issues, &is_nil/1) + + if malformed_count > 0 do + Logger.warning("Dropping malformed Jira issue records count=#{malformed_count}") + end + + issues + |> Enum.reject(&is_nil/1) + |> Enum.filter(&MapSet.member?(requested_states, normalize_state(&1.state))) + end + + defp normalize_requested_issues(raw_issues, requested_ids, settings) do + requested = MapSet.new(requested_ids) + + raw_issues + |> Enum.reduce_while({:ok, %{}}, fn raw_issue, {:ok, issues_by_id} -> + normalize_requested_issue(raw_issue, requested, settings, issues_by_id) + end) + |> case do + {:ok, issues_by_id} -> + {:ok, Enum.flat_map(requested_ids, &(Map.fetch(issues_by_id, &1) |> fetched_issue()))} + + {:error, reason} -> + {:error, reason} + end + end + + defp normalize_requested_issue(%{"id" => id} = raw_issue, requested, settings, issues_by_id) + when is_binary(id) do + project_key = issue_project_key(raw_issue) + + cond do + not MapSet.member?(requested, id) -> + {:cont, {:ok, issues_by_id}} + + is_nil(project_key) -> + {:halt, {:error, :jira_unknown_payload}} + + not same_project_key?(project_key, settings.project_key) -> + {:cont, {:ok, issues_by_id}} + + true -> + case normalize_issue(raw_issue, settings) do + %Issue{} = issue -> {:cont, {:ok, Map.put(issues_by_id, id, issue)}} + nil -> {:halt, {:error, :jira_unknown_payload}} + end + end + end + + defp normalize_requested_issue(_raw_issue, _requested, _settings, _issues_by_id) do + {:halt, {:error, :jira_unknown_payload}} + end + + defp fetched_issue({:ok, issue}), do: [issue] + defp fetched_issue(:error), do: [] + + defp normalize_issue(%{"id" => id, "key" => key, "fields" => fields}, settings) + when is_binary(id) and is_binary(key) and is_map(fields) do + title = fields["summary"] + state = get_in(fields, ["status", "name"]) + + if same_project_key?(issue_project_key(%{"fields" => fields}), settings.project_key) and + present_string?(id) and + present_string?(key) and present_string?(title) and present_string?(state) do + %Issue{ + id: id, + native_ref: nil, + identifier: key, + title: title, + description: description_text(fields["description"]), + priority: nil, + state: state, + branch_name: nil, + url: "#{settings.base_url}/browse/#{URI.encode(key, &URI.char_unreserved?/1)}", + assignee_id: get_in(fields, ["assignee", "accountId"]), + labels: extract_labels(fields["labels"]), + blocked_by: [], + dispatchable: true, + created_at: parse_datetime(fields["created"]), + updated_at: parse_datetime(fields["updated"]) + } + end + end + + defp normalize_issue(_issue, _settings), do: nil + + defp description_text(nil), do: nil + + defp description_text(value) when is_binary(value) do + blank_to_nil(value) + end + + defp description_text(value) when is_map(value) do + value + |> adf_text() + |> blank_to_nil() + end + + defp description_text(_value), do: nil + + defp adf_text(%{"type" => "hardBreak"}), do: "\n" + defp adf_text(%{"text" => text}) when is_binary(text), do: text + + defp adf_text(%{"type" => type, "content" => content}) + when type in ["paragraph", "heading", "blockquote"] and is_list(content) do + Enum.map_join(content, "", &adf_text/1) <> "\n" + end + + defp adf_text(%{"content" => content}) when is_list(content) do + Enum.map_join(content, "", &adf_text/1) + end + + defp adf_text(%{"attrs" => attrs}) when is_map(attrs) do + attrs["text"] || attrs["shortName"] || attrs["url"] || "" + end + + defp adf_text(_value), do: "" + + defp blank_to_nil(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + text -> text + end + end + + defp extract_labels(labels) when is_list(labels) do + labels + |> Enum.filter(&is_binary/1) + |> Enum.map(&(String.trim(&1) |> String.downcase())) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp extract_labels(_labels), do: [] + + defp issue_project_key(raw_issue), do: get_in(raw_issue, ["fields", "project", "key"]) + + defp same_project_key?(left, right) when is_binary(left) and is_binary(right) do + String.downcase(String.trim(left)) == String.downcase(String.trim(right)) + end + + defp same_project_key?(_left, _right), do: false + + defp parse_datetime(value) when is_binary(value) do + normalized = Regex.replace(~r/([+-]\d{2})(\d{2})$/, value, "\\1:\\2") + + case DateTime.from_iso8601(normalized) do + {:ok, datetime, _offset} -> datetime + _ -> nil + end + end + + defp parse_datetime(_value), do: nil + + defp request_with_settings(method, path, query, body, settings, request_fun) do + case request_fun.(method, path, query, body, settings) do + {:ok, %{status: status, body: payload}} when status in 200..299 -> + {:ok, payload} + + {:ok, %{status: status}} when is_integer(status) -> + Logger.error("Jira API request failed status=#{status} method=#{method} path=#{path}") + {:error, {:jira_api_status, status}} + + {:error, reason} -> + {:error, reason} + + _ -> + {:error, :jira_unknown_payload} + end + end + + defp perform_request(method, path, query, body, settings) do + with {:ok, request_method} <- request_method(method) do + request_opts = [ + method: request_method, + url: settings.base_url <> path, + headers: jira_headers(settings.email, settings.api_token), + params: query, + connect_options: [timeout: 30_000] + ] + + request_opts = if is_nil(body), do: request_opts, else: Keyword.put(request_opts, :json, body) + + case Req.request(request_opts) do + {:ok, response} -> {:ok, %{status: response.status, body: response.body}} + {:error, reason} -> {:error, {:jira_api_request, reason}} + end + end + end + + defp settings(tracker_settings) when is_map(tracker_settings) do + provider = provider_settings(tracker_settings) + base_url = resolve_setting(provider["base_url"], System.get_env("JIRA_BASE_URL")) + email = resolve_setting(provider["email"], System.get_env("JIRA_EMAIL")) + api_token = resolve_setting(provider["api_token"], System.get_env("JIRA_API_TOKEN")) + project_key = resolve_setting(provider["project_key"], nil) + + cond do + not valid_base_url?(base_url) -> + {:error, :invalid_jira_base_url} + + not present_string?(email) -> + {:error, :missing_jira_email} + + not present_string?(api_token) -> + {:error, :missing_jira_api_token} + + not present_string?(project_key) -> + {:error, :missing_jira_project_key} + + true -> + {:ok, + %{ + base_url: String.trim_trailing(base_url, "/"), + email: email, + api_token: api_token, + project_key: project_key + }} + end + end + + defp provider_settings(%{provider: provider}) when is_map(provider), do: provider + defp provider_settings(_tracker_settings), do: %{} + + defp resolve_setting(nil, fallback), do: normalize_string(fallback) + + defp resolve_setting("$" <> env_name, fallback) do + if valid_env_name?(env_name) do + normalize_string(System.get_env(env_name) || fallback) + else + nil + end + end + + defp resolve_setting(value, _fallback), do: normalize_string(value) + + defp normalize_string(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_string(_value), do: nil + + defp env_reference_names(values) do + Enum.flat_map(values, fn + "$" <> env_name when is_binary(env_name) -> if valid_env_name?(env_name), do: [env_name], else: [] + _ -> [] + end) + end + + defp valid_env_name?(name), do: String.match?(name, ~r/^[A-Za-z_][A-Za-z0-9_]*$/) + + defp valid_base_url?(value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: "https", host: host, query: nil, fragment: nil} when is_binary(host) -> true + _ -> false + end + end + + defp valid_base_url?(_value), do: false + + defp jira_headers(email, api_token) do + [ + {"Accept", "application/json"}, + {"Authorization", "Basic #{Base.encode64("#{email}:#{api_token}")}"} + ] + end + + defp state_jql(project_key, states) do + quoted_states = Enum.map_join(states, ", ", &jql_string/1) + "project = #{jql_string(project_key)} AND status IN (#{quoted_states})" + end + + defp jql_string(value) do + escaped = value |> String.replace("\\", "\\\\") |> String.replace("\"", "\\\"") + "\"#{escaped}\"" + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp request_method("GET"), do: {:ok, :get} + defp request_method("POST"), do: {:ok, :post} + defp request_method("PUT"), do: {:ok, :put} + defp request_method("DELETE"), do: {:ok, :delete} + defp request_method(_method), do: {:error, :invalid_jira_method} + + defp normalize_state(value) when is_binary(value), do: value |> String.trim() |> String.downcase() + defp normalize_state(_value), do: "" + + defp present_string?(value) when is_binary(value), do: String.trim(value) != "" + defp present_string?(_value), do: false +end diff --git a/elixir/lib/symphony_elixir/tracker.ex b/elixir/lib/symphony_elixir/tracker.ex index 5b5c7aeb74..7b81c2db2e 100644 --- a/elixir/lib/symphony_elixir/tracker.ex +++ b/elixir/lib/symphony_elixir/tracker.ex @@ -11,6 +11,7 @@ defmodule SymphonyElixir.Tracker do alias SymphonyElixir.Tracker.Issue @adapters %{ + "jira" => SymphonyElixir.Jira.Adapter, "linear" => SymphonyElixir.Linear.Adapter, "memory" => SymphonyElixir.Tracker.Memory } diff --git a/elixir/mix.exs b/elixir/mix.exs index eaa80cdfdf..b89cf7734c 100644 --- a/elixir/mix.exs +++ b/elixir/mix.exs @@ -14,6 +14,7 @@ defmodule SymphonyElixir.MixProject do ], ignore_modules: [ SymphonyElixir.Config, + SymphonyElixir.Jira.Client, SymphonyElixir.Linear.Client, SymphonyElixir.SpecsCheck, SymphonyElixir.Orchestrator, diff --git a/elixir/test/symphony_elixir/extensions_test.exs b/elixir/test/symphony_elixir/extensions_test.exs index 0bb92c0512..8fb3417e5f 100644 --- a/elixir/test/symphony_elixir/extensions_test.exs +++ b/elixir/test/symphony_elixir/extensions_test.exs @@ -219,8 +219,8 @@ defmodule SymphonyElixir.ExtensionsTest do "success" ] == false - assert {:error, {:unsupported_tracker_kind, "jira"}} = - SymphonyElixir.Tracker.adapter_for_kind("jira") + assert {:error, {:unsupported_tracker_kind, "future-tracker"}} = + SymphonyElixir.Tracker.adapter_for_kind("future-tracker") write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "linear") assert SymphonyElixir.Tracker.adapter() == Adapter diff --git a/elixir/test/symphony_elixir/jira_adapter_test.exs b/elixir/test/symphony_elixir/jira_adapter_test.exs new file mode 100644 index 0000000000..1ef89e8e64 --- /dev/null +++ b/elixir/test/symphony_elixir/jira_adapter_test.exs @@ -0,0 +1,488 @@ +defmodule SymphonyElixir.Jira.AdapterTest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.Jira.Adapter, as: JiraAdapter + alias SymphonyElixir.Jira.AgentTool, as: JiraAgentTool + alias SymphonyElixir.Jira.Client, as: JiraClient + + defmodule FakeJiraClient do + def fetch_issues_by_states(states) do + send(self(), {:jira_states_called, states}) + {:ok, states} + end + + def fetch_issues_by_ids(ids) do + send(self(), {:jira_ids_called, ids}) + {:ok, ids} + end + end + + setup do + jira_client_module = Application.get_env(:symphony_elixir, :jira_client_module) + + on_exit(fn -> + if is_nil(jira_client_module) do + Application.delete_env(:symphony_elixir, :jira_client_module) + else + Application.put_env(:symphony_elixir, :jira_client_module, jira_client_module) + end + end) + + :ok + end + + test "adapter validates Jira config, delegates reads, and advertises jira_rest" do + settings = tracker_settings() + + assert :ok = JiraAdapter.validate_config(settings) + assert :ok = JiraAdapter.validate_config(%{settings | active_states: [], terminal_states: []}) + + assert {:error, :missing_jira_active_states} = + JiraAdapter.validate_config(%{settings | active_states: nil}) + + assert {:error, :missing_jira_terminal_states} = + JiraAdapter.validate_config(%{settings | terminal_states: nil}) + + assert {:error, :invalid_jira_states} = + JiraAdapter.validate_config(%{settings | active_states: [" "]}) + + assert {:error, :invalid_jira_states} = + JiraAdapter.validate_config(%{settings | active_states: [42]}) + + Application.put_env(:symphony_elixir, :jira_client_module, FakeJiraClient) + + assert {:ok, ["To Do"]} = JiraAdapter.fetch_issues_by_states(["To Do"]) + assert_receive {:jira_states_called, ["To Do"]} + + assert {:ok, ["10001"]} = JiraAdapter.fetch_issues_by_ids(["10001"]) + assert_receive {:jira_ids_called, ["10001"]} + + assert [%{"name" => "jira_rest"}] = JiraAdapter.agent_tool_specs() + + assert JiraAdapter.execute_agent_tool( + "jira_rest", + %{"method" => "GET", "path" => "/rest/api/3/myself"}, + jira_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: 200, body: %{"accountId" => "user"}}} + end + )["success"] + end + + test "client validates provider settings and declares token environments" do + assert :ok = JiraClient.validate_settings(tracker_settings()) + + assert {:error, :invalid_jira_base_url} = + JiraClient.validate_settings(tracker_settings(%{"base_url" => "http://jira.test"})) + + assert {:error, :missing_jira_email} = + JiraClient.validate_settings(tracker_settings(%{"email" => 123})) + + assert {:error, :missing_jira_api_token} = + JiraClient.validate_settings(tracker_settings(%{"api_token" => 123})) + + assert {:error, :missing_jira_project_key} = + JiraClient.validate_settings(tracker_settings(%{"project_key" => 123})) + + assert JiraClient.secret_environment_names(tracker_settings(%{"api_token" => "$SYMPHONY_JIRA_TOKEN"})) == ["JIRA_API_TOKEN", "SYMPHONY_JIRA_TOKEN"] + end + + test "client normalizes Jira issue fields and projects ADF description text" do + issue = JiraClient.normalize_issue_for_test(raw_issue("10001", "SYM-1"), tracker_settings()) + + assert issue.id == "10001" + assert issue.identifier == "SYM-1" + assert issue.native_ref == nil + assert issue.title == "Issue SYM-1" + assert issue.description == "First line\nSecond line" + assert issue.priority == nil + assert issue.state == "To Do" + assert issue.branch_name == nil + assert issue.url == "https://jira.example.test/browse/SYM-1" + assert issue.assignee_id == "account-1" + assert issue.labels == ["bug", "platform"] + assert issue.blocked_by == [] + assert issue.dispatchable + assert %DateTime{} = issue.created_at + assert %DateTime{} = issue.updated_at + + assert JiraClient.normalize_issue_for_test( + put_in(raw_issue("10002", "SYM-2"), ["fields", "summary"], ""), + tracker_settings() + ) == nil + + rich_description = + %{ + "type" => "doc", + "version" => 1, + "content" => [ + %{ + "type" => "paragraph", + "content" => [ + %{"type" => "mention", "attrs" => %{"text" => "@Alex"}}, + %{"type" => "emoji", "attrs" => %{"shortName" => ":wave:"}}, + %{"type" => "status", "attrs" => %{"text" => "Ready"}}, + %{"type" => "inlineCard", "attrs" => %{"url" => "https://example.test/card"}} + ] + } + ] + } + + rich_issue = + raw_issue("10003", "SYM-3") + |> put_in(["fields", "description"], rich_description) + + assert JiraClient.normalize_issue_for_test(rich_issue, tracker_settings()).description == + "@Alex:wave:Readyhttps://example.test/card" + + panel_description = + %{ + "type" => "doc", + "version" => 1, + "content" => [ + %{ + "type" => "panel", + "attrs" => %{"panelType" => "info"}, + "content" => [ + %{"type" => "paragraph", "content" => [%{"type" => "text", "text" => "Panel text"}]} + ] + } + ] + } + + panel_issue = + raw_issue("10004", "SYM-4") + |> put_in(["fields", "description"], panel_description) + + assert JiraClient.normalize_issue_for_test(panel_issue, tracker_settings()).description == + "Panel text" + + assert %Issue{id: "10001"} = + JiraClient.normalize_issue_for_test( + raw_issue("10001", "SYM-1"), + tracker_settings(%{"project_key" => "sym"}) + ) + end + + test "client pages enhanced search, filters states, and drops malformed candidates" do + request_fun = fn "POST", "/rest/api/3/search/jql", %{}, body, settings -> + send(self(), {:jira_search, body, settings}) + + response_body = + case Map.get(body, "nextPageToken") do + nil -> + %{ + "issues" => [ + raw_issue("10001", "SYM-1"), + put_in(raw_issue("10002", "SYM-2"), ["fields", "summary"], ""), + raw_issue("10003", "SYM-3", "Done") + ], + "isLast" => false, + "nextPageToken" => "next-page" + } + + "next-page" -> + %{"issues" => [raw_issue("10004", "SYM-4")], "isLast" => true} + end + + {:ok, %{status: 200, body: response_body}} + end + + log = + capture_log(fn -> + assert {:ok, issues} = + JiraClient.fetch_issues_by_states_for_test( + ["To Do"], + tracker_settings(), + request_fun + ) + + assert Enum.map(issues, & &1.id) == ["10001", "10004"] + end) + + assert log =~ "Dropping malformed Jira issue records count=1" + + assert_receive {:jira_search, + %{ + "jql" => "project = \"SYM\" AND status IN (\"To Do\")", + "fields" => fields, + "maxResults" => 100 + }, %{project_key: "SYM"}} + + assert "summary" in fields + assert_receive {:jira_search, %{"nextPageToken" => "next-page"}, %{project_key: "SYM"}} + + assert {:ok, []} = + JiraClient.fetch_issues_by_states_for_test( + [], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + flunk("empty Jira states should not make an HTTP request") + end + ) + end + + test "client errors when enhanced search pagination omits its token" do + assert {:error, :jira_missing_next_page_token} = + JiraClient.fetch_issues_by_states_for_test( + ["To Do"], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + {:ok, %{status: 200, body: %{"issues" => [], "isLast" => false}}} + end + ) + end + + test "client refreshes IDs in batches, preserves order, and omits missing scope" do + ids = Enum.map(1..101, &Integer.to_string/1) + + request_fun = fn "POST", "/rest/api/3/issue/bulkfetch", %{}, body, _settings -> + send(self(), {:jira_bulk_ids, body["issueIdsOrKeys"]}) + issues = Enum.map(body["issueIdsOrKeys"], &raw_issue(&1, "SYM-#{&1}")) + {:ok, %{status: 200, body: %{"issues" => issues}}} + end + + assert {:ok, issues} = + JiraClient.fetch_issues_by_ids_for_test(ids, tracker_settings(), request_fun) + + assert Enum.map(issues, & &1.id) == ids + assert_receive {:jira_bulk_ids, first_batch} + assert length(first_batch) == 100 + assert_receive {:jira_bulk_ids, ["101"]} + + scoped_request_fun = fn _method, _path, _query, _body, _settings -> + {:ok, + %{ + status: 200, + body: %{ + "issues" => [ + raw_issue("1", "SYM-1"), + raw_issue("2", "OTHER-2", "To Do", "OTHER") + ] + } + }} + end + + assert {:ok, [%Issue{id: "1"}]} = + JiraClient.fetch_issues_by_ids_for_test( + ["1", "2", "404"], + tracker_settings(), + scoped_request_fun + ) + + assert {:error, :jira_unknown_payload} = + JiraClient.fetch_issues_by_ids_for_test( + ["1"], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + malformed = put_in(raw_issue("1", "SYM-1"), ["fields", "summary"], "") + {:ok, %{status: 200, body: %{"issues" => [malformed]}}} + end + ) + end + + test "jira_rest preserves REST status and body while rejecting unsafe arguments" do + test_pid = self() + tracker_settings = tracker_settings() + + response = + JiraAgentTool.execute( + "jira_rest", + %{ + "method" => "post", + "path" => " /rest/api/3/issue/10001/comment ", + "query" => %{"expand" => "renderedBody"}, + "body" => %{"body" => %{"type" => "doc"}} + }, + tracker_settings: tracker_settings, + jira_client: fn method, path, query, body, opts -> + send(test_pid, {:jira_tool_called, method, path, query, body, opts}) + {:ok, %{status: 201, body: %{"id" => "comment-1"}}} + end + ) + + expected_call = + {:jira_tool_called, "POST", "/rest/api/3/issue/10001/comment", %{"expand" => "renderedBody"}, %{"body" => %{"type" => "doc"}}, [tracker_settings: tracker_settings]} + + assert_received ^expected_call + + assert response["success"] + assert Jason.decode!(response["output"]) == %{"status" => 201, "body" => %{"id" => "comment-1"}} + + failure = + JiraAgentTool.execute( + "jira_rest", + %{"method" => "GET", "path" => "/rest/api/3/issue/404"}, + jira_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: 404, body: %{"errorMessages" => ["Not Found"]}}} + end + ) + + refute failure["success"] + + Enum.each( + [ + %{"method" => "GET", "path" => "https://jira.test/rest/api/3/myself"}, + %{"method" => "GET", "path" => "/rest/api/2/myself"}, + %{"method" => "GET", "path" => "/rest/api/3/myself", "query" => false}, + %{"path" => "/rest/api/3/myself"} + ], + fn arguments -> + invalid = + JiraAgentTool.execute( + "jira_rest", + arguments, + jira_client: fn _method, _path, _query, _body, _opts -> + flunk("invalid jira_rest arguments should not call the client") + end + ) + + refute invalid["success"] + end + ) + end + + test "jira_rest reports unsupported tools, malformed calls, and client failures" do + unsupported = JiraAgentTool.execute("not_jira_rest", %{}, []) + refute unsupported["success"] + assert Jason.decode!(unsupported["output"])["error"]["supportedTools"] == ["jira_rest"] + + Enum.each(["not-an-object", %{"method" => "GET", "path" => 123}], fn arguments -> + invalid = + JiraAgentTool.execute( + "jira_rest", + arguments, + jira_client: fn _method, _path, _query, _body, _opts -> + flunk("malformed jira_rest arguments should not call the client") + end + ) + + refute invalid["success"] + end) + + malformed_response = + JiraAgentTool.execute( + "jira_rest", + %{"method" => "GET", "path" => "/rest/api/3/myself"}, + jira_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: "bad", body: %{}}} + end + ) + + refute malformed_response["success"] + + Enum.each([:missing_jira_api_token, {:jira_api_request, :timeout}, :unexpected], fn reason -> + failure = + JiraAgentTool.execute( + "jira_rest", + %{"method" => "GET", "path" => "/rest/api/3/myself"}, + jira_client: fn _method, _path, _query, _body, _opts -> {:error, reason} end + ) + + refute failure["success"] + assert %{"error" => %{"message" => message}} = Jason.decode!(failure["output"]) + assert is_binary(message) + end) + + non_json_body = + JiraAgentTool.execute( + "jira_rest", + %{"method" => "GET", "path" => "/rest/api/3/myself"}, + jira_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: 200, body: self()}} + end + ) + + assert non_json_body["success"] + assert non_json_body["output"] =~ "#PID" + end + + test "tracker binds Jira tools and token env names from provider config" do + token_env = "SYMPHONY_JIRA_TOKEN_#{System.unique_integer([:positive])}" + previous_token = System.get_env(token_env) + System.put_env(token_env, "test-token") + + on_exit(fn -> restore_env(token_env, previous_token) end) + + write_jira_workflow!(Workflow.workflow_file_path(), "$#{token_env}") + + binding = Tracker.bind_agent_tools() + + assert binding.adapter == JiraAdapter + assert binding.secret_environment_names == ["JIRA_API_TOKEN", token_env] + assert [%{"name" => "jira_rest"}] = binding.tool_specs + assert :ok = Config.validate!() + end + + defp tracker_settings(provider_overrides \\ %{}) do + %{ + kind: "jira", + provider: + Map.merge( + %{ + "base_url" => "https://jira.example.test", + "email" => "agent@example.test", + "api_token" => "test-token", + "project_key" => "SYM" + }, + provider_overrides + ), + active_states: ["To Do"], + terminal_states: ["Done"] + } + end + + defp raw_issue(id, key, state \\ "To Do", project_key \\ "SYM") do + %{ + "id" => id, + "key" => key, + "fields" => %{ + "summary" => "Issue #{key}", + "description" => %{ + "type" => "doc", + "version" => 1, + "content" => [ + %{ + "type" => "paragraph", + "content" => [ + %{"type" => "text", "text" => "First line"}, + %{"type" => "hardBreak"}, + %{"type" => "text", "text" => "Second line"} + ] + } + ] + }, + "status" => %{"name" => state}, + "labels" => [" Bug ", "bug", "Platform"], + "assignee" => %{"accountId" => "account-1"}, + "created" => "2026-01-01T00:00:00.000+0000", + "updated" => "2026-01-02T00:00:00.000+0000", + "project" => %{"key" => project_key} + } + } + end + + defp write_jira_workflow!(path, token) do + File.write!( + path, + """ + --- + tracker: + kind: jira + provider: + base_url: "https://jira.example.test" + email: "agent@example.test" + api_token: #{Jason.encode!(token)} + project_key: "SYM" + active_states: ["To Do"] + terminal_states: ["Done"] + --- + + You are working on {{ issue.identifier }}. + """ + ) + + if Process.whereis(SymphonyElixir.WorkflowStore) do + assert :ok = SymphonyElixir.WorkflowStore.force_reload() + end + end +end diff --git a/elixir/test/symphony_elixir/jira_live_e2e_test.exs b/elixir/test/symphony_elixir/jira_live_e2e_test.exs new file mode 100644 index 0000000000..f573a1a27c --- /dev/null +++ b/elixir/test/symphony_elixir/jira_live_e2e_test.exs @@ -0,0 +1,533 @@ +defmodule SymphonyElixir.Jira.LiveE2ETest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.Jira.Client, as: JiraClient + + @moduletag :live_e2e + @moduletag timeout: 300_000 + + @result_file "LIVE_JIRA_E2E_RESULT.txt" + @live_e2e_skip_reason if(System.get_env("SYMPHONY_RUN_JIRA_LIVE_E2E") != "1", + do: "set SYMPHONY_RUN_JIRA_LIVE_E2E=1 to enable the real Jira/Codex end-to-end test" + ) + + @tag skip: @live_e2e_skip_reason + test "creates a real Jira issue and completes it through jira_rest" do + base_url = required_env!("JIRA_BASE_URL") + email = required_env!("JIRA_EMAIL") + api_token = required_env!("JIRA_API_TOKEN") + project_key = required_env!("SYMPHONY_LIVE_JIRA_PROJECT_KEY") + run_id = "symphony-jira-live-e2e-#{System.unique_integer([:positive])}" + test_root = Path.join(System.tmp_dir!(), run_id) + workflow_root = Path.join(test_root, "workflow") + workflow_file = Path.join(workflow_root, "WORKFLOW.md") + workspace_root = Path.join(test_root, "workspaces") + codex_home = isolated_codex_home!(test_root) + original_workflow_path = Workflow.workflow_file_path() + runtime_pid = Process.whereis(SymphonyElixir.AgentRuntimeSupervisor) + issue_type_id = issue_type_id!(base_url, email, api_token, project_key) + + File.mkdir_p!(workflow_root) + + created_issue = + create_issue!( + base_url, + email, + api_token, + project_key, + issue_type_id, + "Symphony Jira live e2e #{run_id}" + ) + + issue_id = created_issue["id"] + issue_key = created_issue["key"] + expected_comment = "Symphony Jira live e2e comment #{issue_key} #{run_id}" + + try do + raw_issue = get_issue!(base_url, email, api_token, issue_id) + initial_state = get_in(raw_issue, ["fields", "status", "name"]) + + {terminal_state, terminal_transition_id} = + terminal_transition!(base_url, email, api_token, issue_id) + + assert is_binary(initial_state) and initial_state != terminal_state + + settings = + tracker_settings(base_url, email, api_token, project_key, initial_state, terminal_state) + + assert %Issue{} = issue = JiraClient.normalize_issue_for_test(raw_issue, settings) + + stop_agent_runtime_if_running(runtime_pid) + Workflow.set_workflow_file_path(workflow_file) + + write_workflow!( + workflow_file, + project_key, + initial_state, + terminal_state, + workspace_root, + codex_home, + live_prompt(issue_id, project_key, terminal_state, expected_comment) + ) + + assert %Issue{id: state_issue_id} = wait_for_state_issue!(issue.id, initial_state) + assert state_issue_id == issue.id + + assert {:ok, [%Issue{id: refreshed_id, identifier: refreshed_identifier}]} = + Tracker.fetch_issues_by_ids([issue.id]) + + assert refreshed_id == issue.id + assert refreshed_identifier == issue.identifier + + assert :ok = AgentRunner.run(issue, self(), max_turns: 3) + + runtime_info = receive_runtime_info!(issue.id) + tool_calls = completed_jira_tool_calls(issue.id) + + assert File.read!(Path.join(runtime_info.workspace_path, @result_file)) == + expected_result(issue.identifier, project_key) + + final_issue = get_issue!(base_url, email, api_token, issue.id, "status,comment") + assert get_in(final_issue, ["fields", "status", "name"]) == terminal_state + assert issue_comments(final_issue) |> Enum.any?(&(&1 == expected_comment)) + + issue_path = "/rest/api/3/issue/#{issue.id}" + comment_path = issue_path <> "/comment" + transitions_path = issue_path <> "/transitions" + + assert_tool_call_count!( + tool_calls, + "GET", + transitions_path, + 1 + ) + + assert_tool_call_count!(tool_calls, "GET", issue_path, 2) + assert_tool_call!(tool_calls, "POST", comment_path, %{"body" => comment_adf(expected_comment)}) + + assert_tool_call!( + tool_calls, + "POST", + transitions_path, + %{"transition" => %{"id" => terminal_transition_id}} + ) + after + delete_result = delete_issue(base_url, email, api_token, issue_id) + + deleted_issue_result = + jira_request( + :get, + base_url, + email, + api_token, + "/rest/api/3/issue/#{issue_id}", + %{} + ) + + Workflow.set_workflow_file_path(original_workflow_path) + restart_agent_runtime_if_needed(runtime_pid) + File.rm_rf(test_root) + assert :ok = delete_result + assert {:ok, %{status: 404}} = deleted_issue_result + end + end + + defp write_workflow!( + path, + project_key, + active_state, + terminal_state, + workspace_root, + codex_home, + prompt + ) do + File.write!( + path, + """ + --- + tracker: + kind: jira + provider: + base_url: "$JIRA_BASE_URL" + email: "$JIRA_EMAIL" + api_token: "$JIRA_API_TOKEN" + project_key: #{Jason.encode!(project_key)} + active_states: [#{Jason.encode!(active_state)}] + terminal_states: [#{Jason.encode!(terminal_state)}] + workspace: + root: #{Jason.encode!(workspace_root)} + agent: + max_turns: 3 + codex: + command: #{Jason.encode!("env CODEX_HOME=#{shell_escape(codex_home)} codex app-server")} + approval_policy: "never" + read_timeout_ms: 60000 + turn_timeout_ms: 600000 + stall_timeout_ms: 600000 + observability: + dashboard_enabled: false + --- + + #{prompt} + """ + ) + + assert :ok = SymphonyElixir.WorkflowStore.force_reload() + end + + defp live_prompt(issue_id, project_key, terminal_state, expected_comment) do + issue_path = "/rest/api/3/issue/#{issue_id}" + comment_path = issue_path <> "/comment" + transitions_path = issue_path <> "/transitions" + comment_body = %{"body" => comment_adf(expected_comment)} |> Jason.encode!() + + """ + You are running a real Symphony Jira end-to-end test. + + The current working directory is the workspace root. + + Step 1: + Create #{@result_file} with exactly: + identifier={{ issue.identifier }} + project_key=#{project_key} + + Step 2: + You must use jira_rest for every Jira operation. First GET: + - #{transitions_path}?expand=transitions.fields + - #{issue_path}?fields=status,comment + + If the exact comment below is not already present, POST it once to #{comment_path}: + #{expected_comment} + + Use this exact JSON body: + #{comment_body} + + Step 3: + Choose the transition whose destination name is #{terminal_state}, then POST its id to #{transitions_path} + with a body shaped as {"transition":{"id":"..."}}. + + Step 4: + GET #{issue_path}?fields=status,comment again. Stop only after: + 1. #{@result_file} exists with the exact two lines above + 2. the exact comment is present + 3. the Jira status name is #{terminal_state} + + Do not ask for approval. + """ + end + + defp expected_result(identifier, project_key) do + "identifier=#{identifier}\nproject_key=#{project_key}\n" + end + + defp tracker_settings(base_url, email, api_token, project_key, active_state, terminal_state) do + %{ + kind: "jira", + provider: %{ + "base_url" => base_url, + "email" => email, + "api_token" => api_token, + "project_key" => project_key + }, + active_states: [active_state], + terminal_states: [terminal_state] + } + end + + defp issue_type_id!(base_url, email, api_token, project_key) do + response = + jira_request!( + :get, + base_url, + email, + api_token, + "/rest/api/3/issue/createmeta/#{URI.encode(project_key, &URI.char_unreserved?/1)}/issuetypes", + %{} + ) + + issue_type = + response.body + |> Map.get("issueTypes", []) + |> Enum.find(&(Map.get(&1, "subtask") != true)) + + case issue_type do + %{"id" => id} when is_binary(id) -> id + _ -> flunk("Jira live e2e could not find a non-subtask issue type") + end + end + + defp terminal_transition!(base_url, email, api_token, issue_id) do + response = + jira_request!( + :get, + base_url, + email, + api_token, + "/rest/api/3/issue/#{issue_id}/transitions", + %{} + ) + + transition = + response.body + |> Map.get("transitions", []) + |> Enum.find(&(get_in(&1, ["to", "statusCategory", "key"]) == "done")) + + case transition do + %{"id" => id, "to" => %{"name" => name}} + when is_binary(id) and id != "" and is_binary(name) and name != "" -> + {name, id} + + _ -> + flunk("Jira live e2e could not find a terminal workflow transition") + end + end + + defp create_issue!(base_url, email, api_token, project_key, issue_type_id, title) do + response = + jira_request!( + :post, + base_url, + email, + api_token, + "/rest/api/3/issue", + %{}, + %{ + "fields" => %{ + "project" => %{"key" => project_key}, + "summary" => title, + "description" => comment_adf(title), + "issuetype" => %{"id" => issue_type_id} + } + } + ) + + case response.body do + %{"id" => id, "key" => key} = issue when is_binary(id) and is_binary(key) -> issue + _ -> flunk("Jira issue create returned an unexpected payload") + end + end + + defp get_issue!(base_url, email, api_token, issue_id, fields \\ "summary,description,status,labels,assignee,created,updated,project") do + response = + jira_request!( + :get, + base_url, + email, + api_token, + "/rest/api/3/issue/#{issue_id}", + %{"fields" => fields} + ) + + case response.body do + %{} = issue -> issue + _ -> flunk("Jira issue read returned an unexpected payload") + end + end + + defp issue_comments(%{"fields" => %{"comment" => %{"comments" => comments}}}) + when is_list(comments) do + Enum.map(comments, &adf_text(&1["body"])) + end + + defp issue_comments(_issue), do: [] + + defp comment_adf(text) do + %{ + "type" => "doc", + "version" => 1, + "content" => [ + %{ + "type" => "paragraph", + "content" => [%{"type" => "text", "text" => text}] + } + ] + } + end + + defp adf_text(%{"text" => text}) when is_binary(text), do: text + + defp adf_text(%{"content" => content}) when is_list(content) do + Enum.map_join(content, "", &adf_text/1) + end + + defp adf_text(_value), do: "" + + defp delete_issue(base_url, email, api_token, issue_id) do + case jira_request( + :delete, + base_url, + email, + api_token, + "/rest/api/3/issue/#{issue_id}", + %{"deleteSubtasks" => "true"} + ) do + {:ok, %{status: status}} when status in 200..299 -> :ok + {:ok, %{status: status}} -> {:error, {:jira_cleanup_status, status}} + {:error, reason} -> {:error, {:jira_cleanup_request, reason}} + end + end + + defp jira_request!(method, base_url, email, api_token, path, query, body \\ nil) do + case jira_request(method, base_url, email, api_token, path, query, body) do + {:ok, %{status: status} = response} when status in 200..299 -> + response + + {:ok, %{status: status}} -> + flunk("Jira request failed with HTTP #{status}") + + {:error, reason} -> + flunk("Jira request failed before a response: #{inspect(reason)}") + end + end + + defp jira_request(method, base_url, email, api_token, path, query, body \\ nil) do + request_opts = [ + method: method, + url: String.trim_trailing(base_url, "/") <> path, + headers: [ + {"Accept", "application/json"}, + {"Authorization", "Basic #{Base.encode64("#{email}:#{api_token}")}"} + ], + params: query, + connect_options: [timeout: 30_000] + ] + + request_opts = if is_nil(body), do: request_opts, else: Keyword.put(request_opts, :json, body) + + case Req.request(request_opts) do + {:ok, response} -> {:ok, %{status: response.status, body: response.body}} + {:error, reason} -> {:error, reason} + end + end + + defp receive_runtime_info!(issue_id) do + receive do + {:worker_runtime_info, ^issue_id, %{workspace_path: workspace_path} = runtime_info} + when is_binary(workspace_path) -> + runtime_info + + {:codex_worker_update, ^issue_id, _message} -> + receive_runtime_info!(issue_id) + after + 5_000 -> + flunk("timed out waiting for worker runtime info") + end + end + + defp completed_jira_tool_calls(issue_id, calls \\ []) do + receive do + {:codex_worker_update, ^issue_id, %{event: :tool_call_completed, payload: %{"params" => params}}} -> + completed_jira_tool_calls(issue_id, [params | calls]) + + {:codex_worker_update, ^issue_id, _message} -> + completed_jira_tool_calls(issue_id, calls) + after + 0 -> + Enum.reverse(calls) + end + end + + defp assert_tool_call!(calls, method, path, expected_body) do + found? = + Enum.any?(calls, fn params -> + tool_call_matches?(params, method, path) and + get_in(params, ["arguments", "body"]) == expected_body + end) + + assert found?, "expected completed jira_rest #{method} #{path}" + end + + defp assert_tool_call_count!(calls, method, path, expected_count) do + count = Enum.count(calls, &tool_call_matches?(&1, method, path)) + assert count >= expected_count, "expected at least #{expected_count} completed jira_rest #{method} #{path} calls" + end + + defp tool_call_matches?(params, method, path) do + arguments = Map.get(params, "arguments", %{}) + tool_name = Map.get(params, "name") || Map.get(params, "tool") + called_method = Map.get(arguments, "method") + called_path = Map.get(arguments, "path") + + tool_name == "jira_rest" and is_binary(called_method) and + String.upcase(String.trim(called_method)) == method and + is_binary(called_path) and String.trim(called_path) == path + end + + defp wait_for_state_issue!(issue_id, state, attempts \\ 20) + + defp wait_for_state_issue!(issue_id, state, attempts) when attempts > 0 do + case Tracker.fetch_issues_by_states([state]) do + {:ok, issues} -> + case Enum.find(issues, &(&1.id == issue_id)) do + %Issue{} = issue -> + issue + + nil -> + Process.sleep(500) + wait_for_state_issue!(issue_id, state, attempts - 1) + end + + {:error, reason} -> + flunk("Jira state read failed: #{inspect(reason)}") + end + end + + defp wait_for_state_issue!(_issue_id, _state, 0) do + flunk("new Jira issue did not appear in the adapter state read") + end + + defp isolated_codex_home!(test_root) do + codex_home = Path.join(test_root, "codex-home") + auth_json_path = Path.join(codex_home, "auth.json") + + source_auth_json = + Path.join( + System.get_env("CODEX_HOME") || Path.join(System.user_home!(), ".codex"), + "auth.json" + ) + + unless File.regular?(source_auth_json) do + flunk("live Jira e2e requires Codex auth") + end + + File.mkdir_p!(codex_home) + File.cp!(source_auth_json, auth_json_path) + File.chmod!(auth_json_path, 0o600) + codex_home + end + + defp stop_agent_runtime_if_running(runtime_pid) when is_pid(runtime_pid) do + assert :ok = + Supervisor.terminate_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) + end + + defp stop_agent_runtime_if_running(_runtime_pid), do: :ok + + defp restart_agent_runtime_if_needed(runtime_pid) when is_pid(runtime_pid) do + if is_nil(Process.whereis(SymphonyElixir.AgentRuntimeSupervisor)) do + case Supervisor.restart_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + end + end + end + + defp restart_agent_runtime_if_needed(_runtime_pid), do: :ok + + defp required_env!(name) do + case System.get_env(name) do + value when is_binary(value) and value != "" -> value + _ -> flunk("live Jira e2e requires #{name}") + end + end + + defp shell_escape(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end +end From b8c23840026240eda5731f5616a5e13314bf1f1d Mon Sep 17 00:00:00 2001 From: Alex Kotliarskyi Date: Mon, 20 Jul 2026 10:55:40 -0700 Subject: [PATCH 2/2] docs(jira): simplify adapter README Summary: - Reduce the Jira adapter notes to configuration, scoped reads, and host-authenticated tool behavior. - Keep the live test command while removing implementation narration. Rationale: - The README should explain how to use the adapter without duplicating provider implementation details or error catalogs. Tests: - git diff --check Co-authored-by: Codex --- elixir/README.md | 47 +++++++++++------------------------------------ 1 file changed, 11 insertions(+), 36 deletions(-) diff --git a/elixir/README.md b/elixir/README.md index 1a07a2c5ac..fd2cea03f1 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -238,36 +238,15 @@ codex: `tracker_payload`, and missing cursors to `tracker_pagination`; logs and tool responses carry the human-readable provider detail. -### Jira Cloud adapter profile - -- Config: use `tracker.kind: jira` with `tracker.provider.base_url` (defaults to - `JIRA_BASE_URL` and accepts `$VAR`), `email` (defaults to `JIRA_EMAIL`), `api_token` - (defaults to `JIRA_API_TOKEN` and accepts `$VAR`), and required `project_key`. - Set explicit provider-native `active_states` and `terminal_states`; Jira workflows are - tenant-defined, so Symphony does not invent status defaults. -- Scope and paging: candidate reads use Jira enhanced search with project/status JQL and follow - opaque `nextPageToken` pages of 100. ID refreshes use `issue/bulkfetch` in batches of 100 and - omit missing or out-of-project issues. Empty state/ID lists return `{:ok, []}` without a Jira - request. -- Identity and normalization: `issue.id` is Jira's immutable issue ID and `issue.identifier` is - the issue key. State keeps Jira's status spelling; ADF descriptions are projected to plain text, - labels are trimmed/lowercased/deduplicated, assignee account IDs are preserved, and Jira's - `+0000` timestamp offsets are parsed. The adapter leaves priority and blockers unset because - those semantics are tenant-specific. -- Tool: the adapter advertises `jira_rest`, accepting `method`, a relative path beginning with - `/rest/api/3/`, optional object `query`, and optional JSON `body`. Symphony executes it host-side - with Basic auth and strips `JIRA_API_TOKEN` plus any configured `$VAR` token name from the Codex - child. Scheduler reads are project-scoped; the raw tool can access whatever the configured Jira - credential can access. -- Responsibility and errors: `jira_rest` adds no idempotency key, retry, scope guard, or - rate-limit policy. It preserves REST `status` and `body` in the tool result, with non-2xx - responses marked `"success" => false`. Read/config failures use - `{:error, :missing_jira_active_states}`, `{:error, :missing_jira_terminal_states}`, - `{:error, :invalid_jira_states}`, `{:error, :invalid_jira_base_url}`, - `{:error, :missing_jira_email}`, `{:error, :missing_jira_api_token}`, - `{:error, :missing_jira_project_key}`, `{:error, {:jira_api_status, status}}`, - `{:error, {:jira_api_request, reason}}`, `{:error, :jira_unknown_payload}`, or - `{:error, :jira_missing_next_page_token}`. +### Jira Cloud adapter + +- Config: use `tracker.kind: jira` with provider `base_url`, `email`, `api_token`, and required + `project_key`; the first three default to `JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_API_TOKEN` + and accept `$VAR`. Set explicit Jira-native `active_states` and `terminal_states`. +- Issues and reads: candidate reads and ID refreshes stay scoped to the configured project and + requested statuses; `issue.id` is Jira's immutable ID and `issue.identifier` is the issue key. +- Tool: `jira_rest` sends relative `/rest/api/3/` requests host-side with configured Basic auth, + strips token environment variables from Codex, and can reach whatever the Jira credential can. ## Web dashboard @@ -322,8 +301,8 @@ The live test creates a temporary Linear project and issue, writes a temporary ` a real agent turn, verifies the workspace side effect, requires Codex to comment on and close the Linear issue, then marks the project completed so the run remains visible in Linear. -Run the opt-in Jira Cloud live test with a disposable project whose credential can browse, create, -comment on, transition, and delete issues: +Run the opt-in Jira Cloud live test against a disposable project whose credential can browse, +create, comment on, transition, and delete issues: ```bash cd elixir @@ -334,10 +313,6 @@ export SYMPHONY_LIVE_JIRA_PROJECT_KEY=TEST SYMPHONY_RUN_JIRA_LIVE_E2E=1 mix test test/symphony_elixir/jira_live_e2e_test.exs ``` -It creates one disposable issue, verifies both tracker reads, runs a real local Codex worker, -requires `jira_rest` to post an exact comment and transition the issue to an available terminal -status, verifies the workspace file and direct Jira readback, then deletes the test issue. - ## FAQ ### Why Elixir?