diff --git a/elixir/README.md b/elixir/README.md index 25f5411874..4cfbdc5df3 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 GitHub Issues) 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 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. 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,20 @@ codex: `tracker_payload`, and missing cursors to `tracker_pagination`; logs and tool responses carry the human-readable provider detail. +### GitHub Issues adapter + +- Config: use `tracker.kind: github` with required `tracker.provider.repo` in `owner/repo` form, + optional `token` (defaults to `GITHUB_TOKEN` and accepts `$VAR`), and optional `api_url` + (default `https://api.github.com`, HTTPS only). Set explicit `active_states` and + `terminal_states`; active entries may be `open` and terminal entries may be `closed`. +- Reads and identity: polling is scoped to the configured repository; `issue.id` is the + repository issue number, `issue.identifier` is `GH-`, hidden or deleted `404` issues are + omitted on refresh, and pull requests returned by the Issues API are not dispatchable. +- Tool and auth: `github_api` accepts a relative REST `path` plus optional `params` and JSON + `body`; Symphony executes it host-side with the session-bound token, strips `GITHUB_TOKEN` and + configured `$VAR` token names from the Codex child, and leaves raw tool access limited by that + token's GitHub permissions. + ## Web dashboard The observability UI now runs on a minimal Phoenix stack: @@ -291,6 +305,15 @@ 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 GitHub Issues live test with a disposable/scratch repository: + +```bash +cd elixir +export SYMPHONY_LIVE_GITHUB_REPO=owner/scratch-repo +export GITHUB_TOKEN=... +SYMPHONY_RUN_GITHUB_LIVE_E2E=1 mix test test/symphony_elixir/github_live_e2e_test.exs +``` + ## FAQ ### Why Elixir? diff --git a/elixir/lib/symphony_elixir/github/adapter.ex b/elixir/lib/symphony_elixir/github/adapter.ex new file mode 100644 index 0000000000..5a770167ee --- /dev/null +++ b/elixir/lib/symphony_elixir/github/adapter.ex @@ -0,0 +1,63 @@ +defmodule SymphonyElixir.GitHub.Adapter do + @moduledoc """ + GitHub Issues-backed tracker adapter. + """ + + @behaviour SymphonyElixir.Tracker + + alias SymphonyElixir.GitHub.{AgentTool, Client} + alias SymphonyElixir.Tracker.Issue + + @active_states ["open"] + @terminal_states ["closed"] + + @spec validate_config(map()) :: :ok | {:error, term()} + def validate_config(tracker_settings) do + with :ok <- + validate_states( + tracker_settings.active_states, + @active_states, + :missing_github_active_states + ), + :ok <- + validate_states( + tracker_settings.terminal_states, + @terminal_states, + :missing_github_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(issue_ids), do: client_module().fetch_issues_by_ids(issue_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, :github_client_module, Client) + end + + defp validate_states(states, allowed_states, _missing_error) when is_list(states) do + if Enum.all?(states, &(normalize_state(&1) in allowed_states)) do + :ok + else + {:error, :invalid_github_states} + end + end + + defp validate_states(_states, _allowed_states, missing_error), do: {:error, missing_error} + + defp normalize_state(state) when is_binary(state), do: state |> String.trim() |> String.downcase() + defp normalize_state(_state), do: "" +end diff --git a/elixir/lib/symphony_elixir/github/agent_tool.ex b/elixir/lib/symphony_elixir/github/agent_tool.ex new file mode 100644 index 0000000000..9b42aa767f --- /dev/null +++ b/elixir/lib/symphony_elixir/github/agent_tool.ex @@ -0,0 +1,173 @@ +defmodule SymphonyElixir.GitHub.AgentTool do + @moduledoc """ + Provider-native GitHub REST tool exposed to Codex app-server turns. + """ + + alias SymphonyElixir.GitHub.Client + + @github_api_tool "github_api" + @allowed_methods ["GET", "POST", "PATCH", "PUT", "DELETE"] + @github_api_description """ + Execute a GitHub REST API request using Symphony's configured auth. + """ + @github_api_input_schema %{ + "type" => "object", + "additionalProperties" => false, + "required" => ["method", "path"], + "properties" => %{ + "method" => %{ + "type" => "string", + "enum" => @allowed_methods, + "description" => "GitHub REST method." + }, + "path" => %{ + "type" => "string", + "description" => "GitHub REST path such as /repos/owner/repo/issues/1/comments." + }, + "params" => %{ + "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 + @github_api_tool -> execute_github_api(arguments, opts) + other -> unsupported_tool_response(other) + end + end + + @spec tool_specs() :: [map()] + def tool_specs do + [ + %{ + "name" => @github_api_tool, + "description" => @github_api_description, + "inputSchema" => @github_api_input_schema + } + ] + end + + defp execute_github_api(arguments, opts) do + github_client = Keyword.get(opts, :github_client, &Client.request/5) + client_opts = Keyword.take(opts, [:tracker_settings]) + + with {:ok, method, path, params, body} <- normalize_arguments(arguments), + {:ok, %{status: status, body: response_body}} <- + github_client.(method, path, params, 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(:github_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, params} <- normalize_params(Map.get(arguments, "params")) do + {:ok, method, path, params, 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, "/") 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_params(nil), do: {:ok, %{}} + defp normalize_params(params) when is_map(params), do: {:ok, params} + defp normalize_params(_params), do: {:error, :invalid_params} + + 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" => "`github_api` expects an object with `method` and `path`."}} + end + + defp tool_error_payload(:invalid_method) do + %{"error" => %{"message" => "`github_api.method` must be GET, POST, PATCH, PUT, or DELETE."}} + end + + defp tool_error_payload(:invalid_path) do + %{"error" => %{"message" => "`github_api.path` must be a relative GitHub REST path."}} + end + + defp tool_error_payload(:invalid_params) do + %{"error" => %{"message" => "`github_api.params` must be a JSON object when provided."}} + end + + defp tool_error_payload(:missing_github_token) do + %{ + "error" => %{ + "message" => "Symphony is missing GitHub auth. Set `tracker.provider.token` in `WORKFLOW.md` or export `GITHUB_TOKEN`." + } + } + end + + defp tool_error_payload({:github_api_request, reason}) do + %{ + "error" => %{ + "message" => "GitHub API request failed before receiving a successful response.", + "reason" => inspect(reason) + } + } + end + + defp tool_error_payload(reason) do + %{"error" => %{"message" => "GitHub API 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/github/client.ex b/elixir/lib/symphony_elixir/github/client.ex new file mode 100644 index 0000000000..6e3f170fbd --- /dev/null +++ b/elixir/lib/symphony_elixir/github/client.ex @@ -0,0 +1,392 @@ +defmodule SymphonyElixir.GitHub.Client do + @moduledoc """ + Thin GitHub REST client for repository issue polling. + """ + + require Logger + alias SymphonyElixir.Config + alias SymphonyElixir.Tracker.Issue + + @default_api_url "https://api.github.com" + @api_version "2022-11-28" + @page_size 100 + @user_agent "symphony" + + @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) + + ["GITHUB_TOKEN" | env_reference_names([provider["token"]])] + |> Enum.uniq() + end + + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states(state_names) when is_list(state_names) do + fetch_issues_by_states(state_names, Config.settings!().tracker, &perform_request/5) + end + + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(issue_ids) when is_list(issue_ids) do + fetch_issues_by_ids(issue_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, params, body, opts \\ []) + when is_binary(method) and is_binary(path) and is_map(params) 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, github_settings} <- settings(tracker_settings) do + request_fun.(method, path, params, body, github_settings) + end + end + + @doc false + @spec normalize_issue_for_test(map(), String.t()) :: Issue.t() | nil + def normalize_issue_for_test(issue, repo) when is_map(issue) and is_binary(repo) do + normalize_issue(issue, repo) + 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(state_names, tracker_settings, request_fun) + when is_list(state_names) and is_map(tracker_settings) and is_function(request_fun, 5) do + fetch_issues_by_states(state_names, 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(issue_ids, tracker_settings, request_fun) + when is_list(issue_ids) and is_map(tracker_settings) and is_function(request_fun, 5) do + fetch_issues_by_ids(issue_ids, tracker_settings, request_fun) + end + + defp fetch_issues_by_states(state_names, tracker_settings, request_fun) do + normalized_states = state_names |> Enum.map(&normalize_state/1) |> MapSet.new() + + case github_state_query(normalized_states) do + nil -> + {:ok, []} + + state_query -> + with {:ok, github_settings} <- settings(tracker_settings) do + do_fetch_pages(github_settings, state_query, normalized_states, 1, request_fun, []) + end + end + end + + defp fetch_issues_by_ids(issue_ids, tracker_settings, request_fun) do + ids = Enum.uniq(issue_ids) + + case ids do + [] -> + {:ok, []} + + ids -> + with {:ok, github_settings} <- settings(tracker_settings) do + fetch_issue_ids(ids, github_settings, request_fun, []) + end + end + end + + defp do_fetch_pages(settings, state_query, requested_states, page, request_fun, acc) do + params = %{ + "state" => state_query, + "per_page" => @page_size, + "page" => page, + "sort" => "created", + "direction" => "asc" + } + + with {:ok, payload} <- + request_with_settings( + "GET", + repository_issues_path(settings), + params, + nil, + settings, + request_fun, + false + ), + true <- is_list(payload) or {:error, :github_unknown_payload} do + issues = normalize_state_page(payload, settings.repo, requested_states) + updated_acc = [issues | acc] + + if length(payload) < @page_size do + {:ok, updated_acc |> Enum.reverse() |> List.flatten()} + else + do_fetch_pages(settings, state_query, requested_states, page + 1, request_fun, updated_acc) + end + end + end + + defp fetch_issue_ids([], _settings, _request_fun, acc), do: {:ok, Enum.reverse(acc)} + + defp fetch_issue_ids([id | rest], settings, request_fun, acc) do + with {:ok, issue_number} <- parse_issue_number(id), + {:ok, payload} <- + request_with_settings( + "GET", + repository_issue_path(settings, issue_number), + %{}, + nil, + settings, + request_fun, + true + ) do + continue_issue_id_fetch(payload, rest, settings, request_fun, acc) + end + end + + defp continue_issue_id_fetch(:not_found, rest, settings, request_fun, acc) do + fetch_issue_ids(rest, settings, request_fun, acc) + end + + defp continue_issue_id_fetch(%{} = raw_issue, rest, settings, request_fun, acc) do + case normalize_issue(raw_issue, settings.repo) do + %Issue{} = issue -> fetch_issue_ids(rest, settings, request_fun, [issue | acc]) + nil -> {:error, :github_unknown_payload} + end + end + + defp continue_issue_id_fetch(_payload, _rest, _settings, _request_fun, _acc) do + {:error, :github_unknown_payload} + end + + defp normalize_state_page(payload, repo, requested_states) do + issues = Enum.map(payload, &normalize_issue(&1, repo)) + malformed_count = Enum.count(issues, &is_nil/1) + + if malformed_count > 0 do + Logger.warning("Dropping malformed GitHub 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_issue(issue, repo) when is_map(issue) and is_binary(repo) do + issue_number = issue["number"] + state = issue["state"] + + if is_integer(issue_number) and issue_number > 0 and + Enum.all?([issue["title"], state], &present_string?/1) do + %Issue{ + id: Integer.to_string(issue_number), + native_ref: native_ref(issue, repo), + identifier: "GH-#{issue_number}", + title: issue["title"], + description: issue["body"], + state: state, + url: issue["html_url"], + assignee_id: get_in(issue, ["assignee", "login"]), + labels: extract_labels(issue), + blocked_by: [], + dispatchable: not Map.has_key?(issue, "pull_request"), + created_at: parse_datetime(issue["created_at"]), + updated_at: parse_datetime(issue["updated_at"]) + } + end + end + + defp normalize_issue(_issue, _repo), do: nil + + defp native_ref(issue, repo) do + %{ + "id" => issue["id"], + "node_id" => issue["node_id"], + "number" => issue["number"], + "repo" => repo + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + |> case do + empty when map_size(empty) == 0 -> nil + ref -> ref + end + end + + defp extract_labels(%{"labels" => labels}) when is_list(labels) do + labels + |> Enum.flat_map(fn + %{"name" => name} when is_binary(name) -> [name] + name when is_binary(name) -> [name] + _ -> [] + end) + |> Enum.map(&(String.trim(&1) |> String.downcase())) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp extract_labels(_issue), do: [] + + defp parse_datetime(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> datetime + _ -> nil + end + end + + defp parse_datetime(_value), do: nil + + defp request_with_settings(method, path, params, body, settings, request_fun, allow_not_found) do + case request_fun.(method, path, params, body, settings) do + {:ok, %{status: status, body: payload}} when status in 200..299 -> + {:ok, payload} + + {:ok, %{status: 404}} when allow_not_found -> + {:ok, :not_found} + + {:ok, %{status: status}} when is_integer(status) -> + Logger.error("GitHub API request failed status=#{status} method=#{method} path=#{path}") + {:error, {:github_api_status, status}} + + {:error, reason} -> + {:error, reason} + + _ -> + {:error, :github_unknown_payload} + end + end + + defp perform_request(method, path, params, body, settings) do + with {:ok, request_method} <- request_method(method) do + request_opts = [ + method: request_method, + url: settings.api_url <> path, + headers: github_headers(settings.token), + params: params, + 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, {:github_api_request, reason}} + end + end + end + + defp settings(tracker_settings) when is_map(tracker_settings) do + provider = provider_settings(tracker_settings) + api_url = provider["api_url"] || @default_api_url + repo = resolve_setting(provider["repo"], System.get_env("GITHUB_REPO")) + token = resolve_setting(provider["token"], System.get_env("GITHUB_TOKEN")) + + cond do + not valid_api_url?(api_url) -> {:error, :invalid_github_api_url} + not present_string?(repo) -> {:error, :missing_github_repo} + not valid_repo?(repo) -> {:error, :invalid_github_repo} + not present_string?(token) -> {:error, :missing_github_token} + true -> {:ok, %{api_url: String.trim_trailing(api_url, "/"), repo: repo, token: token}} + 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_api_url?(value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: "https", host: host} when is_binary(host) -> true + _ -> false + end + end + + defp valid_api_url?(_value), do: false + defp valid_repo?(repo) when is_binary(repo), do: String.match?(repo, ~r/^[^\s\/]+\/[^\s\/]+$/) + defp valid_repo?(_repo), do: false + + defp repository_issues_path(settings), do: "/repos/#{encoded_repo(settings.repo)}/issues" + + defp repository_issue_path(settings, issue_number), + do: "#{repository_issues_path(settings)}/#{issue_number}" + + defp encoded_repo(repo) do + repo + |> String.split("/", parts: 2) + |> Enum.map_join("/", fn segment -> URI.encode(segment, &URI.char_unreserved?/1) end) + end + + defp github_headers(token) do + [ + {"Accept", "application/vnd.github+json"}, + {"Authorization", "Bearer #{token}"}, + {"X-GitHub-Api-Version", @api_version}, + {"User-Agent", @user_agent} + ] + end + + defp github_state_query(states) do + has_open? = MapSet.member?(states, "open") + has_closed? = MapSet.member?(states, "closed") + + cond do + has_open? and has_closed? -> "all" + has_open? -> "open" + has_closed? -> "closed" + true -> nil + end + end + + defp parse_issue_number(value) when is_binary(value) do + case Integer.parse(value) do + {number, ""} when number > 0 -> {:ok, number} + _ -> {:error, :invalid_github_issue_id} + end + end + + defp parse_issue_number(_value), do: {:error, :invalid_github_issue_id} + + defp request_method("GET"), do: {:ok, :get} + defp request_method("POST"), do: {:ok, :post} + defp request_method("PATCH"), do: {:ok, :patch} + defp request_method("PUT"), do: {:ok, :put} + defp request_method("DELETE"), do: {:ok, :delete} + defp request_method(_method), do: {:error, :invalid_github_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..2da9b6673e 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 %{ + "github" => SymphonyElixir.GitHub.Adapter, "linear" => SymphonyElixir.Linear.Adapter, "memory" => SymphonyElixir.Tracker.Memory } diff --git a/elixir/mix.exs b/elixir/mix.exs index eaa80cdfdf..dd52915c21 100644 --- a/elixir/mix.exs +++ b/elixir/mix.exs @@ -14,6 +14,7 @@ defmodule SymphonyElixir.MixProject do ], ignore_modules: [ SymphonyElixir.Config, + SymphonyElixir.GitHub.Client, SymphonyElixir.Linear.Client, SymphonyElixir.SpecsCheck, SymphonyElixir.Orchestrator, diff --git a/elixir/test/symphony_elixir/github_adapter_test.exs b/elixir/test/symphony_elixir/github_adapter_test.exs new file mode 100644 index 0000000000..09115a26f8 --- /dev/null +++ b/elixir/test/symphony_elixir/github_adapter_test.exs @@ -0,0 +1,439 @@ +defmodule SymphonyElixir.GitHub.AdapterTest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.GitHub.Adapter, as: GitHubAdapter + alias SymphonyElixir.GitHub.AgentTool, as: GitHubAgentTool + alias SymphonyElixir.GitHub.Client, as: GitHubClient + + defmodule FakeGitHubClient do + def fetch_issues_by_states(states) do + send(self(), {:github_states_called, states}) + {:ok, states} + end + + def fetch_issues_by_ids(ids) do + send(self(), {:github_ids_called, ids}) + {:ok, ids} + end + end + + setup do + github_client_module = Application.get_env(:symphony_elixir, :github_client_module) + + on_exit(fn -> + if is_nil(github_client_module) do + Application.delete_env(:symphony_elixir, :github_client_module) + else + Application.put_env(:symphony_elixir, :github_client_module, github_client_module) + end + end) + + :ok + end + + test "adapter validates GitHub config, delegates reads, and advertises github_api" do + settings = tracker_settings() + + assert :ok = GitHubAdapter.validate_config(settings) + + assert {:error, :missing_github_active_states} = + GitHubAdapter.validate_config(%{settings | active_states: nil}) + + assert {:error, :missing_github_terminal_states} = + GitHubAdapter.validate_config(%{settings | terminal_states: nil}) + + assert :ok = GitHubAdapter.validate_config(%{settings | active_states: [], terminal_states: []}) + + assert {:error, :invalid_github_states} = + GitHubAdapter.validate_config(%{settings | active_states: ["Todo"]}) + + assert {:error, :invalid_github_states} = + GitHubAdapter.validate_config(%{settings | active_states: [42]}) + + assert {:error, :invalid_github_states} = + GitHubAdapter.validate_config(%{settings | active_states: ["closed"]}) + + assert {:error, :invalid_github_states} = + GitHubAdapter.validate_config(%{settings | terminal_states: ["open"]}) + + Application.put_env(:symphony_elixir, :github_client_module, FakeGitHubClient) + + assert {:ok, ["open"]} = GitHubAdapter.fetch_issues_by_states(["open"]) + assert_receive {:github_states_called, ["open"]} + + assert {:ok, ["42"]} = GitHubAdapter.fetch_issues_by_ids(["42"]) + assert_receive {:github_ids_called, ["42"]} + + assert [%{"name" => "github_api"}] = GitHubAdapter.agent_tool_specs() + + assert GitHubAdapter.execute_agent_tool( + "github_api", + %{"method" => "GET", "path" => "/user"}, + github_client: fn _method, _path, _params, _body, _opts -> + {:ok, %{status: 200, body: %{"login" => "octocat"}}} + end + )["success"] + end + + test "client validates repository settings and declares token environments" do + assert :ok = GitHubClient.validate_settings(tracker_settings()) + + assert {:error, :missing_github_repo} = + GitHubClient.validate_settings(tracker_settings(%{"repo" => 123})) + + assert {:error, :invalid_github_repo} = + GitHubClient.validate_settings(tracker_settings(%{"repo" => "not-a-repo"})) + + assert {:error, :missing_github_token} = + GitHubClient.validate_settings(tracker_settings(%{"token" => 123})) + + assert {:error, :invalid_github_api_url} = + GitHubClient.validate_settings(tracker_settings(%{"api_url" => "not a url"})) + + assert {:error, :invalid_github_api_url} = + GitHubClient.validate_settings(tracker_settings(%{"api_url" => "http://api.github.com"})) + + assert GitHubClient.secret_environment_names(tracker_settings(%{"token" => "$SYMPHONY_GITHUB_TOKEN"})) == ["GITHUB_TOKEN", "SYMPHONY_GITHUB_TOKEN"] + end + + test "client normalizes GitHub issues without dropping provider details" do + issue = GitHubClient.normalize_issue_for_test(raw_issue(42), "octo/repo") + + assert issue.id == "42" + assert issue.identifier == "GH-42" + + assert issue.native_ref == %{ + "id" => 1_042, + "node_id" => "I_42", + "number" => 42, + "repo" => "octo/repo" + } + + assert issue.title == "Issue 42" + assert issue.description == "Body 42" + assert issue.state == "open" + assert issue.url == "https://github.test/octo/repo/issues/42" + assert issue.assignee_id == "octocat" + assert issue.labels == ["bug", "platform"] + assert issue.blocked_by == [] + assert issue.dispatchable + assert %DateTime{} = issue.created_at + assert %DateTime{} = issue.updated_at + + refute GitHubClient.normalize_issue_for_test( + Map.put(raw_issue(43), "pull_request", %{"url" => "https://api.github.test/pulls/43"}), + "octo/repo" + ).dispatchable + + assert GitHubClient.normalize_issue_for_test( + Map.put(raw_issue(44), "title", " "), + "octo/repo" + ) == nil + end + + test "client pages state reads, filters requested states, and drops malformed records" do + first_page = + Enum.map(1..97, &raw_issue/1) ++ + [ + Map.put(raw_issue(98), "pull_request", %{"url" => "https://api.github.test/pulls/98"}), + Map.put(raw_issue(99), "state", "closed"), + Map.put(raw_issue(100), "title", "") + ] + + request_fun = fn "GET", "/repos/octo/repo/issues", params, nil, settings -> + send(self(), {:github_page, params, settings}) + + body = + case params["page"] do + 1 -> first_page + 2 -> [raw_issue(101)] + end + + {:ok, %{status: 200, body: body}} + end + + log = + capture_log(fn -> + assert {:ok, issues} = + GitHubClient.fetch_issues_by_states_for_test( + [" OPEN "], + tracker_settings(), + request_fun + ) + + assert length(issues) == 99 + assert hd(issues).id == "1" + assert List.last(issues).id == "101" + refute Enum.any?(issues, &(&1.id == "99")) + assert Enum.find(issues, &(&1.id == "98")).dispatchable == false + end) + + assert log =~ "Dropping malformed GitHub issue records count=1" + + assert_receive {:github_page, + %{ + "state" => "open", + "per_page" => 100, + "page" => 1, + "sort" => "created", + "direction" => "asc" + }, %{repo: "octo/repo"}} + + assert_receive {:github_page, %{"page" => 2}, %{repo: "octo/repo"}} + + assert {:ok, []} = + GitHubClient.fetch_issues_by_states_for_test( + ["In Progress"], + tracker_settings(), + fn _method, _path, _params, _body, _settings -> + flunk("unsupported GitHub states should not make an HTTP request") + end + ) + end + + test "client refreshes numeric IDs in order, omits 404s, and rejects malformed refreshes" do + request_fun = fn "GET", path, %{}, nil, _settings -> + send(self(), {:github_id_path, path}) + + case path do + "/repos/octo/repo/issues/2" -> {:ok, %{status: 200, body: raw_issue(2)}} + "/repos/octo/repo/issues/1" -> {:ok, %{status: 200, body: raw_issue(1)}} + "/repos/octo/repo/issues/404" -> {:ok, %{status: 404, body: %{"message" => "Not Found"}}} + end + end + + assert {:ok, issues} = + GitHubClient.fetch_issues_by_ids_for_test( + ["2", "1", "404", "2"], + tracker_settings(), + request_fun + ) + + assert Enum.map(issues, & &1.id) == ["2", "1"] + assert_receive {:github_id_path, "/repos/octo/repo/issues/2"} + assert_receive {:github_id_path, "/repos/octo/repo/issues/1"} + assert_receive {:github_id_path, "/repos/octo/repo/issues/404"} + refute_receive {:github_id_path, "/repos/octo/repo/issues/2"} + + assert {:error, :invalid_github_issue_id} = + GitHubClient.fetch_issues_by_ids_for_test( + ["not-a-number"], + tracker_settings(), + request_fun + ) + + assert {:error, :github_unknown_payload} = + GitHubClient.fetch_issues_by_ids_for_test( + ["3"], + tracker_settings(), + fn _method, _path, _params, _body, _settings -> + {:ok, %{status: 200, body: Map.put(raw_issue(3), "title", "")}} + end + ) + end + + test "github_api preserves REST status and body while rejecting unsafe arguments" do + test_pid = self() + tracker_settings = tracker_settings() + + response = + GitHubAgentTool.execute( + "github_api", + %{ + "method" => "post", + "path" => " /repos/octo/repo/issues/42/comments ", + "params" => %{"per_page" => 10}, + "body" => %{"body" => "hello"} + }, + tracker_settings: tracker_settings, + github_client: fn method, path, params, body, opts -> + send(test_pid, {:github_tool_called, method, path, params, body, opts}) + {:ok, %{status: 201, body: %{"id" => 9}}} + end + ) + + assert_received {:github_tool_called, "POST", "/repos/octo/repo/issues/42/comments", %{"per_page" => 10}, %{"body" => "hello"}, [tracker_settings: ^tracker_settings]} + + assert response["success"] == true + assert Jason.decode!(response["output"]) == %{"status" => 201, "body" => %{"id" => 9}} + assert response["contentItems"] == [%{"type" => "inputText", "text" => response["output"]}] + + failure = + GitHubAgentTool.execute( + "github_api", + %{"method" => "GET", "path" => "/repos/octo/repo/issues/404"}, + github_client: fn _method, _path, _params, _body, _opts -> + {:ok, %{status: 404, body: %{"message" => "Not Found"}}} + end + ) + + assert failure["success"] == false + + assert Jason.decode!(failure["output"]) == %{ + "status" => 404, + "body" => %{"message" => "Not Found"} + } + + Enum.each( + [ + %{"method" => "GET", "path" => "https://api.github.com/user"}, + %{"method" => "GET", "path" => "/user", "params" => false}, + %{"path" => "/user"} + ], + fn arguments -> + invalid = + GitHubAgentTool.execute( + "github_api", + arguments, + github_client: fn _method, _path, _params, _body, _opts -> + flunk("invalid github_api arguments should not call the client") + end + ) + + assert invalid["success"] == false + end + ) + end + + test "github_api reports unsupported tools, malformed calls, and client failures" do + unsupported = GitHubAgentTool.execute("not_github_api", %{}, []) + assert unsupported["success"] == false + assert Jason.decode!(unsupported["output"])["error"]["supportedTools"] == ["github_api"] + + Enum.each( + [ + "not-an-object", + %{"method" => "GET", "path" => 123} + ], + fn arguments -> + invalid = + GitHubAgentTool.execute( + "github_api", + arguments, + github_client: fn _method, _path, _params, _body, _opts -> + flunk("malformed github_api arguments should not call the client") + end + ) + + assert invalid["success"] == false + end + ) + + malformed_response = + GitHubAgentTool.execute( + "github_api", + %{"method" => "GET", "path" => "/user"}, + github_client: fn _method, _path, _params, _body, _opts -> + {:ok, %{status: "not-an-integer", body: %{}}} + end + ) + + assert malformed_response["success"] == false + + Enum.each( + [ + :missing_github_token, + {:github_api_request, :timeout}, + :unexpected_failure + ], + fn reason -> + failure = + GitHubAgentTool.execute( + "github_api", + %{"method" => "GET", "path" => "/user"}, + github_client: fn _method, _path, _params, _body, _opts -> + {:error, reason} + end + ) + + assert failure["success"] == false + assert %{"error" => %{"message" => message}} = Jason.decode!(failure["output"]) + assert is_binary(message) + end + ) + + non_json_body = + GitHubAgentTool.execute( + "github_api", + %{"method" => "GET", "path" => "/user"}, + github_client: fn _method, _path, _params, _body, _opts -> + {:ok, %{status: 200, body: self()}} + end + ) + + assert non_json_body["success"] + assert non_json_body["output"] =~ "#PID" + end + + test "tracker binds GitHub tools and token env names from provider config" do + token_env = "SYMPHONY_GITHUB_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_github_workflow!(Workflow.workflow_file_path(), "$#{token_env}") + + binding = Tracker.bind_agent_tools() + + assert binding.adapter == GitHubAdapter + assert binding.secret_environment_names == ["GITHUB_TOKEN", token_env] + assert [%{"name" => "github_api"}] = binding.tool_specs + assert :ok = Config.validate!() + end + + defp tracker_settings(provider_overrides \\ %{}) do + %{ + kind: "github", + provider: + Map.merge( + %{ + "repo" => "octo/repo", + "token" => "test-token" + }, + provider_overrides + ), + active_states: ["open"], + terminal_states: ["closed"] + } + end + + defp raw_issue(number) do + %{ + "number" => number, + "id" => 1_000 + number, + "node_id" => "I_#{number}", + "title" => "Issue #{number}", + "body" => "Body #{number}", + "state" => "open", + "html_url" => "https://github.test/octo/repo/issues/#{number}", + "assignee" => %{"login" => "octocat"}, + "labels" => [%{"name" => " Bug "}, %{"name" => "bug"}, %{"name" => "Platform"}], + "created_at" => "2026-01-01T00:00:00Z", + "updated_at" => "2026-01-02T00:00:00Z" + } + end + + defp write_github_workflow!(path, token) do + File.write!( + path, + """ + --- + tracker: + kind: github + provider: + repo: "octo/repo" + token: #{Jason.encode!(token)} + active_states: ["open"] + terminal_states: ["closed"] + --- + + 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/github_live_e2e_test.exs b/elixir/test/symphony_elixir/github_live_e2e_test.exs new file mode 100644 index 0000000000..5e95f9ab53 --- /dev/null +++ b/elixir/test/symphony_elixir/github_live_e2e_test.exs @@ -0,0 +1,402 @@ +defmodule SymphonyElixir.GitHub.LiveE2ETest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.GitHub.Client, as: GitHubClient + + @moduletag :live_e2e + @moduletag timeout: 300_000 + + @api_url "https://api.github.com" + @api_version "2022-11-28" + @result_file "LIVE_GITHUB_E2E_RESULT.txt" + @live_e2e_skip_reason if(System.get_env("SYMPHONY_RUN_GITHUB_LIVE_E2E") != "1", + do: "set SYMPHONY_RUN_GITHUB_LIVE_E2E=1 to enable the real GitHub/Codex end-to-end test" + ) + + @tag skip: @live_e2e_skip_reason + test "creates a real GitHub issue and closes it through github_api" do + repo = required_env!("SYMPHONY_LIVE_GITHUB_REPO") + token = required_env!("GITHUB_TOKEN") + run_id = "symphony-github-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) + + File.mkdir_p!(workflow_root) + + issue_payload = + create_issue!( + repo, + token, + "Symphony GitHub live e2e #{run_id}", + "Disposable issue created by the Symphony GitHub live E2E test." + ) + + issue_number = Integer.to_string(issue_payload["number"]) + expected_comment = expected_comment("GH-#{issue_number}", run_id) + + try do + assert %Issue{} = issue = GitHubClient.normalize_issue_for_test(issue_payload, repo) + stop_agent_runtime_if_running(runtime_pid) + Workflow.set_workflow_file_path(workflow_file) + + write_workflow!( + workflow_file, + repo, + workspace_root, + codex_home, + live_prompt(repo, expected_comment) + ) + + assert %Issue{id: state_issue_id} = wait_for_open_state_issue!(issue.id) + assert state_issue_id == issue.id + + assert {:ok, [%Issue{id: issue_id, identifier: identifier}]} = + Tracker.fetch_issues_by_ids([issue.id]) + + assert issue_id == issue.id + assert identifier == issue.identifier + + assert :ok = AgentRunner.run(issue, self(), max_turns: 3) + + runtime_info = receive_runtime_info!(issue.id) + tool_calls = completed_github_tool_calls(issue.id) + + assert File.read!(Path.join(runtime_info.workspace_path, @result_file)) == + expected_result(issue.identifier, repo) + + assert %{"state" => "closed"} = get_issue!(repo, token, issue.id) + + assert comments!(repo, token, issue.id) + |> Enum.any?(&(&1["body"] == expected_comment)) + + issue_path = "/repos/#{encoded_repo(repo)}/issues/#{issue.id}" + comments_path = issue_path <> "/comments" + + assert_tool_call_count!(tool_calls, "GET", issue_path, 2) + assert_tool_call_count!(tool_calls, "GET", comments_path, 2) + assert_tool_call!(tool_calls, "POST", comments_path, %{"body" => expected_comment}) + assert_tool_call!(tool_calls, "PATCH", issue_path, %{"state" => "closed"}) + after + close_result = close_issue(repo, token, issue_number) + Workflow.set_workflow_file_path(original_workflow_path) + restart_agent_runtime_if_needed(runtime_pid) + File.rm_rf(test_root) + assert :ok = close_result + end + end + + defp write_workflow!(path, repo, workspace_root, codex_home, prompt) do + File.write!( + path, + """ + --- + tracker: + kind: github + provider: + repo: #{Jason.encode!(repo)} + token: "$GITHUB_TOKEN" + active_states: ["open"] + terminal_states: ["closed"] + 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(repo, expected_comment) do + issue_path = "/repos/#{encoded_repo(repo)}/issues/{{ issue.id }}" + comments_path = issue_path <> "/comments" + + """ + You are running a real Symphony GitHub end-to-end test. + + The current working directory is the workspace root. + + Step 1: + Run exactly: + + ```sh + cat > #{@result_file} < title, "body" => body} + ) + + case response.body do + %{"number" => number} = issue when is_integer(number) -> issue + _ -> flunk("GitHub issue create returned an unexpected payload") + end + end + + defp get_issue!(repo, token, issue_id) do + response = github_request!(:get, "/repos/#{encoded_repo(repo)}/issues/#{issue_id}", token) + + case response.body do + %{} = issue -> issue + _ -> flunk("GitHub issue read returned an unexpected payload") + end + end + + defp comments!(repo, token, issue_id) do + response = + github_request!( + :get, + "/repos/#{encoded_repo(repo)}/issues/#{issue_id}/comments", + token, + nil, + %{"per_page" => 100} + ) + + case response.body do + comments when is_list(comments) -> comments + _ -> flunk("GitHub issue comments read returned an unexpected payload") + end + end + + defp close_issue(repo, token, issue_id) do + case github_request( + :patch, + "/repos/#{encoded_repo(repo)}/issues/#{issue_id}", + token, + %{"state" => "closed"} + ) do + {:ok, %{status: status}} when status in 200..299 -> :ok + {:ok, %{status: status}} -> {:error, {:github_cleanup_status, status}} + {:error, reason} -> {:error, {:github_cleanup_request, reason}} + end + end + + defp github_request!(method, path, token, body \\ nil, params \\ %{}) do + case github_request(method, path, token, body, params) do + {:ok, %{status: status} = response} when status in 200..299 -> + response + + {:ok, %{status: status}} -> + flunk("GitHub request failed with HTTP #{status}") + + {:error, reason} -> + flunk("GitHub request failed before a response: #{inspect(reason)}") + end + end + + defp github_request(method, path, token, body, params \\ %{}) do + request_opts = [ + method: method, + url: @api_url <> path, + headers: [ + {"Accept", "application/vnd.github+json"}, + {"Authorization", "Bearer #{token}"}, + {"X-GitHub-Api-Version", @api_version}, + {"User-Agent", "symphony-live-e2e"} + ], + params: params, + 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_github_tool_calls(issue_id, calls \\ []) do + receive do + {:codex_worker_update, ^issue_id, %{event: :tool_call_completed, payload: %{"params" => params}}} -> + completed_github_tool_calls(issue_id, [params | calls]) + + {:codex_worker_update, ^issue_id, _message} -> + completed_github_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 + (expected_body == :any or get_in(params, ["arguments", "body"]) == expected_body) + end) + + assert found?, "expected completed github_api #{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 github_api #{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 == "github_api" 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_open_state_issue!(issue_id, attempts \\ 20) + + defp wait_for_open_state_issue!(issue_id, attempts) when attempts > 0 do + case Tracker.fetch_issues_by_states(["open"]) do + {:ok, issues} -> + case Enum.find(issues, &(&1.id == issue_id)) do + %Issue{} = issue -> + issue + + nil -> + Process.sleep(500) + wait_for_open_state_issue!(issue_id, attempts - 1) + end + + {:error, reason} -> + flunk("GitHub state read failed: #{inspect(reason)}") + end + end + + defp wait_for_open_state_issue!(_issue_id, 0) do + flunk("new GitHub issue did not appear in the open-state adapter 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 GitHub 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 GitHub e2e requires #{name}") + end + end + + defp encoded_repo(repo) do + repo + |> String.split("/", parts: 2) + |> Enum.map_join("/", fn segment -> URI.encode(segment, &URI.char_unreserved?/1) end) + end + + defp shell_escape(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end +end