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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@ This directory contains the current Elixir/OTP implementation of Symphony, based

## How it works

1. Polls the configured tracker for candidate work (included adapters: Linear and GitHub Issues)
1. Polls the configured tracker for candidate work (included adapters: Linear, GitHub Issues, 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
4. Sends a workflow prompt to Codex
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
Linear adapter serves `linear_graphql`; the GitHub Issues adapter serves `github_api`. 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.
Linear serves `linear_graphql`, GitHub Issues serves `github_api`, and Jira Cloud 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.
Expand Down Expand Up @@ -252,6 +254,16 @@ codex:
configured `$VAR` token names from the Codex child, and leaves raw tool access limited by that
token's GitHub permissions.

### 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

The observability UI now runs on a minimal Phoenix stack:
Expand Down Expand Up @@ -314,6 +326,18 @@ export GITHUB_TOKEN=...
SYMPHONY_RUN_GITHUB_LIVE_E2E=1 mix test test/symphony_elixir/github_live_e2e_test.exs
```

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
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
```

## FAQ

### Why Elixir?
Expand Down
50 changes: 50 additions & 0 deletions elixir/lib/symphony_elixir/jira/adapter.ex
Original file line number Diff line number Diff line change
@@ -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
174 changes: 174 additions & 0 deletions elixir/lib/symphony_elixir/jira/agent_tool.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading