From 368b2747ae72036e73e532e491a4012c3b4d1a6c Mon Sep 17 00:00:00 2001 From: Peter vogel Date: Fri, 6 Mar 2026 02:47:57 +0100 Subject: [PATCH] feat(elixir): add full-agent multi-project issue runs Summary: - add a real `full_agent` runner for GitHub and GitLab projects that clones an isolated workspace, runs Codex, and posts the generated issue reply back to the tracker - fix multi-project retry behavior by stabilizing issue signatures, replacing the invalid build timeout handling, and clearing trigger labels after each run to prevent comment spam loops - document the new fork behavior and add regression coverage for the workspace, agent runner, and orchestrator helpers Rationale: - multi-project mode needed a true issue-driven Codex path instead of routing `full_agent` through the same build-only pipeline - the original build runner bug and label retry loop made GitHub issue testing noisy and hard to validate in practice - writing the final reply into `.symphony/issue_comment.md` keeps the agent run deterministic and lets Symphony own the final tracker post Tests: - cd elixir && mise exec -- mix test - cd elixir && mise exec -- mix specs.check - cd elixir && mise exec -- mix credo --strict - live-tested full_agent against RaistlinMuc/symphony#2 and verified the `todo` label was removed and a single joke comment was posted Co-authored-by: Codex --- CHANGELOG.md | 9 +- README.md | 7 +- elixir/README.md | 23 +- .../multi_project_agent_runner.ex | 75 +++++ .../multi_project_orchestrator.ex | 115 +++++-- .../multi_project_prompt_builder.ex | 93 ++++++ .../multi_project_workspace.ex | 283 ++++++++++++++++++ .../symphony_elixir/tracker/github/adapter.ex | 3 + .../symphony_elixir/tracker/github/client.ex | 13 + .../symphony_elixir/tracker/gitlab/adapter.ex | 3 + .../symphony_elixir/tracker/gitlab/client.ex | 11 + elixir/lib/symphony_elixir/tracker/router.ex | 5 + .../multi_project_agent_runner_test.exs | 164 ++++++++++ .../multi_project_orchestrator_test.exs | 66 ++++ 14 files changed, 845 insertions(+), 25 deletions(-) create mode 100644 elixir/lib/symphony_elixir/multi_project_agent_runner.ex create mode 100644 elixir/lib/symphony_elixir/multi_project_prompt_builder.ex create mode 100644 elixir/lib/symphony_elixir/multi_project_workspace.ex create mode 100644 elixir/test/symphony_elixir/multi_project_agent_runner_test.exs create mode 100644 elixir/test/symphony_elixir/multi_project_orchestrator_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c33a4033..8b3c6351d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,14 +11,21 @@ All notable changes to this fork are documented in this file. - New project management API endpoints under `/api/v1/projects/*`. - New tracker adapters and routing for GitHub and GitLab. - Multi-project orchestration runtime with per-project polling/backoff/build execution. +- Full-agent workspace cloning and prompt generation for GitHub/GitLab issue runs. - Dashboard sections for discovered and monitored projects. - Regression tests for project registry and project discovery. +- Regression tests for multi-project workspace and full-agent execution. ### Changed - Updated root and Elixir README to describe fork-specific behavior and setup. - Extended observability payloads to include optional project context fields. +- Fixed build command timeout handling to use task-based timeouts instead of invalid `System.cmd/3` + options. +- Added automatic trigger-label removal after successful or failed runs to avoid comment spam and + retry loops. ### Notes - Legacy Linear single-workflow path remains available. - `build_only` mode is fully wired. -- `full_agent` mode exists as configuration and currently follows the same build pipeline in this fork revision. +- `full_agent` mode now runs a real Codex workflow and posts the generated issue comment back to + the tracker. diff --git a/README.md b/README.md index fa1ba61e3..6c5306650 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,11 @@ Implemented additions: Notes: - Legacy Linear-based single-workflow mode remains available. -- In the current fork state, `build_only` is fully wired. `full_agent` is present as a mode and - config path and currently executes the same build job pipeline. +- `build_only` runs configured build/test commands and posts the result back to the tracked issue. +- `full_agent` now launches a real Codex issue run in an isolated workspace clone and posts the + generated issue reply back to GitHub or GitLab. +- Trigger labels such as `todo` are removed automatically after a run finishes to avoid retry + loops and comment spam. ## Running Symphony diff --git a/elixir/README.md b/elixir/README.md index 47b573983..7475e1f61 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -83,10 +83,29 @@ mise exec -- ./bin/symphony ./WORKFLOW.md --port 4100 --i-understand-that-this-w ### Fork status and current limitations - `build_only` mode is fully implemented (poll -> build commands -> post result comment). -- `full_agent` is available as a project mode/config value and currently runs through the same - build job path in this fork revision. +- `full_agent` mode is fully wired for GitHub and GitLab issue polling: + - Symphony clones the tracked repository into an isolated workspace + - launches Codex in that workspace + - instructs Codex to write the exact issue reply into `.symphony/issue_comment.md` + - then posts that markdown back to the source issue +- Trigger labels such as `todo` are removed automatically after each run to prevent repeated + re-processing of the same issue. - The existing Linear single-workflow path remains available for legacy usage. +### Typical GitHub flow in this fork + +1. Add a monitored project for your repository. +2. Set `mode` to `full_agent`. +3. Add a trigger label filter such as `todo`. +4. Open an issue and apply the `todo` label. +5. Symphony polls the issue, runs Codex in an isolated workspace clone, then posts the generated + markdown reply back to the issue. + +For GitHub, Symphony can authenticate with either: + +- `GITHUB_TOKEN` in the Symphony process environment +- or the local GitHub CLI session via `gh auth token` + ## How to use it 1. Make sure your codebase is set up to work well with agents: see diff --git a/elixir/lib/symphony_elixir/multi_project_agent_runner.ex b/elixir/lib/symphony_elixir/multi_project_agent_runner.ex new file mode 100644 index 000000000..7bd7b56da --- /dev/null +++ b/elixir/lib/symphony_elixir/multi_project_agent_runner.ex @@ -0,0 +1,75 @@ +defmodule SymphonyElixir.MultiProjectAgentRunner do + @moduledoc """ + Executes a multi-project full-agent run in an isolated workspace clone. + """ + + require Logger + + alias SymphonyElixir.Codex.AppServer + alias SymphonyElixir.{MultiProjectPromptBuilder, MultiProjectWorkspace, Project} + alias SymphonyElixir.Tracker.Issue + + @spec run(Project.t(), Issue.t(), keyword()) :: + {:ok, %{summary: String.t(), comment_body: String.t(), workspace: String.t()}} | {:error, term()} + def run(%Project{} = project, %Issue{} = issue, opts \\ []) do + app_server_module = Keyword.get(opts, :app_server_module, AppServer) + app_server_opts = Keyword.get(opts, :app_server_opts, []) + workspace_opts = Keyword.get(opts, :workspace_opts, []) + + with {:ok, workspace} <- MultiProjectWorkspace.create_for_issue(project, issue, workspace_opts), + prompt <- build_prompt(project, issue, workspace), + {:ok, _result} <- + app_server_module.run( + workspace.path, + prompt, + issue, + Keyword.merge(app_server_opts, issue_comment_path: workspace.comment_file) + ), + {:ok, comment_body} <- read_issue_comment(workspace.comment_file) do + {:ok, + %{ + summary: summarize_comment(comment_body), + comment_body: comment_body, + workspace: workspace.path + }} + else + {:error, reason} -> + Logger.error("full-agent run failed project_id=#{project.id} issue_id=#{issue.id} reason=#{inspect(reason)}") + + {:error, reason} + end + end + + @spec build_prompt(Project.t(), Issue.t(), MultiProjectWorkspace.workspace_info()) :: String.t() + defp build_prompt(%Project{} = project, %Issue{} = issue, workspace) do + MultiProjectPromptBuilder.build_prompt(project, issue, workspace.path, comment_file: workspace.comment_file) + end + + @spec read_issue_comment(String.t()) :: {:ok, String.t()} | {:error, term()} + defp read_issue_comment(path) when is_binary(path) do + case File.read(path) do + {:ok, body} -> + trimmed = String.trim(body) + + if trimmed == "" do + {:error, :missing_agent_issue_comment} + else + {:ok, trimmed} + end + + {:error, reason} -> + {:error, {:missing_agent_issue_comment, reason}} + end + end + + @spec summarize_comment(String.t()) :: String.t() + defp summarize_comment(comment_body) do + comment_body + |> String.split("\n", trim: true) + |> List.first() + |> case do + nil -> "agent completed" + line -> String.slice(line, 0, 120) + end + end +end diff --git a/elixir/lib/symphony_elixir/multi_project_orchestrator.ex b/elixir/lib/symphony_elixir/multi_project_orchestrator.ex index 0a720d9fa..a67df6dd8 100644 --- a/elixir/lib/symphony_elixir/multi_project_orchestrator.ex +++ b/elixir/lib/symphony_elixir/multi_project_orchestrator.ex @@ -6,7 +6,7 @@ defmodule SymphonyElixir.MultiProjectOrchestrator do use GenServer require Logger - alias SymphonyElixir.{Config, Project, ProjectRegistry, Tracker} + alias SymphonyElixir.{Config, MultiProjectAgentRunner, Project, ProjectRegistry, Tracker} alias SymphonyElixir.Tracker.Issue @default_retry_after_ms 60_000 @@ -275,13 +275,28 @@ defmodule SymphonyElixir.MultiProjectOrchestrator do end @spec run_issue_job(Project.t(), Issue.t()) :: {:ok, String.t()} | {:error, term()} + defp run_issue_job(%Project{mode: "full_agent"} = project, %Issue{} = issue) do + with {:ok, agent_result} <- MultiProjectAgentRunner.run(project, issue), + :ok <- post_full_agent_comment(project, issue, agent_result.comment_body) do + maybe_clear_trigger_labels(project, issue) + {:ok, agent_result.summary} + else + {:error, reason} -> + _ = post_failure_comment(project, issue, reason) + maybe_clear_trigger_labels(project, issue) + {:error, reason} + end + end + defp run_issue_job(%Project{} = project, %Issue{} = issue) do with {:ok, build_result} <- run_build(project), :ok <- post_result_comment(project, issue, build_result) do + maybe_clear_trigger_labels(project, issue) {:ok, build_result.summary} else {:error, reason} -> _ = post_failure_comment(project, issue, reason) + maybe_clear_trigger_labels(project, issue) {:error, reason} end end @@ -293,6 +308,21 @@ defmodule SymphonyElixir.MultiProjectOrchestrator do build_result(commands, build_working_dir(project), timeout_ms) end + @spec run_command_for_test(String.t(), String.t(), non_neg_integer()) :: {:ok, String.t()} | {:error, term()} + def run_command_for_test(command, working_dir, timeout_ms) do + run_command(command, working_dir, timeout_ms) + end + + @spec issue_signature_for_test(Issue.t()) :: String.t() + def issue_signature_for_test(%Issue{} = issue) do + issue_signature(issue) + end + + @spec track_issues_for_test(map(), [Issue.t()]) :: map() + def track_issues_for_test(existing, issues) when is_map(existing) and is_list(issues) do + track_issues(existing, issues) + end + defp build_result([], _working_dir, _timeout_ms) do {:ok, %{summary: "no build commands configured", output: ""}} end @@ -325,20 +355,29 @@ defmodule SymphonyElixir.MultiProjectOrchestrator do @spec run_command(String.t(), String.t(), non_neg_integer()) :: {:ok, String.t()} | {:error, term()} defp run_command(command, working_dir, timeout_ms) do - {output, status} = - System.cmd("bash", ["-lc", command], - cd: working_dir, - stderr_to_stdout: true, - timeout: timeout_ms - ) - - if status == 0 do - {:ok, output} - else - {:error, {:exit_status, status, truncate_output(output)}} + task = + Task.async(fn -> + try do + case System.cmd("bash", ["-lc", command], cd: working_dir, stderr_to_stdout: true) do + {output, 0} -> + {:ok, output} + + {output, status} -> + {:error, {:exit_status, status, truncate_output(output)}} + end + rescue + error -> {:error, {:command_error, error}} + end + end) + + case Task.yield(task, timeout_ms) do + {:ok, result} -> + result + + nil -> + _ = Task.shutdown(task, :brutal_kill) + {:error, {:command_timeout, timeout_ms}} end - rescue - error -> {:error, {:command_error, error}} end @spec post_result_comment( @@ -381,6 +420,14 @@ defmodule SymphonyElixir.MultiProjectOrchestrator do Tracker.Router.create_comment(project, issue.id, body) end + @spec post_full_agent_comment(Project.t(), Issue.t(), String.t()) :: :ok | {:error, term()} + defp post_full_agent_comment(project, issue, body) + when is_binary(body) do + Logger.info("posting full-agent issue comment project_id=#{project.id} issue_id=#{issue.id} bytes=#{byte_size(body)}") + + Tracker.Router.create_comment(project, issue.id, body) + end + @spec filter_candidate_issues([Issue.t()], Project.t()) :: [Issue.t()] defp filter_candidate_issues(issues, %Project{} = project) do active_states = project |> Project.active_states() |> Enum.map(&normalize_state/1) |> MapSet.new() @@ -411,17 +458,45 @@ defmodule SymphonyElixir.MultiProjectOrchestrator do end) end + @spec maybe_clear_trigger_labels(Project.t(), Issue.t()) :: :ok + defp maybe_clear_trigger_labels(%Project{} = project, %Issue{} = issue) do + include_labels = MapSet.new(Project.labels_include(project)) + + if MapSet.size(include_labels) == 0 do + :ok + else + remaining_labels = + issue.labels + |> Enum.reject(&MapSet.member?(include_labels, String.downcase(&1))) + + case Tracker.Router.replace_labels(project, issue.id, remaining_labels) do + :ok -> + :ok + + {:error, reason} -> + Logger.warning("failed to clear trigger labels project_id=#{project.id} issue_id=#{issue.id} reason=#{inspect(reason)}") + + :ok + end + end + end + @spec track_issues(map(), [Issue.t()]) :: map() - defp track_issues(existing, issues) when is_map(existing) do - Enum.reduce(issues, existing, fn issue, acc -> - Map.put(acc, issue.id, issue_signature(issue)) - end) + defp track_issues(_existing, issues) do + Map.new(issues, fn issue -> {issue.id, issue_signature(issue)} end) end @spec issue_signature(Issue.t()) :: String.t() defp issue_signature(%Issue{} = issue) do - updated = iso8601(issue.updated_at) || "" - updated <> "|" <> normalize_state(issue.state) + [ + normalize_state(issue.state), + Atom.to_string(issue.source || :issue), + issue.title || "", + issue.description || "", + issue.branch_name || "", + issue.labels |> Enum.map(&String.downcase/1) |> Enum.sort() |> Enum.join(",") + ] + |> Enum.join("|") end @spec put_project_state(State.t(), String.t(), map()) :: State.t() diff --git a/elixir/lib/symphony_elixir/multi_project_prompt_builder.ex b/elixir/lib/symphony_elixir/multi_project_prompt_builder.ex new file mode 100644 index 000000000..c78a6f996 --- /dev/null +++ b/elixir/lib/symphony_elixir/multi_project_prompt_builder.ex @@ -0,0 +1,93 @@ +defmodule SymphonyElixir.MultiProjectPromptBuilder do + @moduledoc """ + Builds provider-neutral prompts for multi-project full-agent runs. + """ + + alias SymphonyElixir.Project + alias SymphonyElixir.Tracker.Issue + + @spec build_prompt(Project.t(), Issue.t(), String.t(), keyword()) :: String.t() + def build_prompt(%Project{} = project, %Issue{} = issue, workspace, opts \\ []) + when is_binary(workspace) do + comment_file = + opts + |> Keyword.get(:comment_file, Path.join(workspace, ".symphony/issue_comment.md")) + |> Path.expand() + + """ + You are Symphony operating in full_agent mode for a tracked #{provider_label(project)} item. + + Repository: #{repository_label(project)} + Provider: #{project.provider} + Issue: #{issue.identifier} - #{issue.title} + URL: #{issue.url || "n/a"} + Labels: #{format_labels(issue.labels)} + Workspace: #{Path.expand(workspace)} + Comment file: #{comment_file} + + Issue description: + #{issue.description || "No description provided."} + + Instructions: + 1. Work only inside this cloned repository workspace. + 2. Complete the task described by the issue body. If the task can be completed by posting a comment only, avoid unnecessary code changes. + 3. Do not post issue comments directly through #{provider_label(project)}. Symphony will post exactly the contents of `#{comment_file}` after your run. + 4. Before ending your turn, write the exact markdown issue comment for Symphony to post into `#{comment_file}`. + 5. Keep the comment file ready to post as-is. If you created code changes, include branch, commit, validation, and PR/MR URL in that comment. + 6. If you are blocked, explain the blocker in the comment file instead of asking a human follow-up question. + 7. You may use shell tools, git, and repository-local tooling available in the workspace. + #{provider_specific_instructions(project)} + """ + |> String.trim() + end + + @spec repository_label(Project.t()) :: String.t() + defp repository_label(%Project{provider: "github", provider_config: provider_config}) do + "#{provider_config["owner"]}/#{provider_config["repo"]}" + end + + defp repository_label(%Project{provider: "gitlab", provider_config: provider_config}) do + provider_config["project_path"] || provider_config["project_id"] || "gitlab-project" + end + + defp repository_label(%Project{name: name}), do: name + + @spec provider_label(Project.t()) :: String.t() + defp provider_label(%Project{provider: "github"}), do: "GitHub" + defp provider_label(%Project{provider: "gitlab"}), do: "GitLab" + defp provider_label(_project), do: "tracker" + + @spec provider_specific_instructions(Project.t()) :: String.t() + defp provider_specific_instructions(%Project{provider: "github", provider_config: provider_config}) do + """ + GitHub notes: + - `gh` CLI is available and authenticated for `#{provider_config["owner"]}/#{provider_config["repo"]}`. + - `origin` points at the tracked repository so normal branch/push/PR flows can use git and `gh`. + - Do not use `gh issue comment`; write the desired issue reply into the comment file instead. + """ + |> String.trim() + end + + defp provider_specific_instructions(%Project{provider: "gitlab", provider_config: provider_config}) do + """ + GitLab notes: + - `origin` points at the tracked GitLab repository#{gitlab_host_suffix(provider_config)}. + - Do not post issue comments directly; write the desired issue reply into the comment file instead. + """ + |> String.trim() + end + + defp provider_specific_instructions(_project), do: "" + + @spec gitlab_host_suffix(map()) :: String.t() + defp gitlab_host_suffix(provider_config) do + case provider_config["base_url"] do + nil -> "" + base_url -> " on #{base_url}" + end + end + + @spec format_labels([String.t()]) :: String.t() + defp format_labels([]), do: "(none)" + defp format_labels(labels), do: Enum.join(labels, ", ") +end diff --git a/elixir/lib/symphony_elixir/multi_project_workspace.ex b/elixir/lib/symphony_elixir/multi_project_workspace.ex new file mode 100644 index 000000000..0f219f825 --- /dev/null +++ b/elixir/lib/symphony_elixir/multi_project_workspace.ex @@ -0,0 +1,283 @@ +defmodule SymphonyElixir.MultiProjectWorkspace do + @moduledoc """ + Creates isolated workspaces for multi-project full-agent runs. + """ + + require Logger + + alias SymphonyElixir.{Config, Project} + alias SymphonyElixir.Tracker.Issue + + @comment_rel_path ".symphony/issue_comment.md" + + @type workspace_info :: %{ + path: String.t(), + comment_file: String.t(), + clone_source: String.t() + } + + @spec create_for_issue(Project.t(), Issue.t(), keyword()) :: {:ok, workspace_info()} | {:error, term()} + def create_for_issue(%Project{} = project, %Issue{} = issue, opts \\ []) do + workspace = workspace_path(project, issue) + + with :ok <- validate_workspace_path(workspace), + :ok <- reset_workspace(workspace), + {:ok, clone_source, origin_url} <- clone_details(project, opts), + :ok <- clone_repo(clone_source, origin_url, workspace), + :ok <- copy_git_identity(project.repo_path, workspace), + :ok <- initialize_workspace_files(workspace) do + {:ok, + %{ + path: workspace, + comment_file: Path.join(workspace, @comment_rel_path), + clone_source: clone_source + }} + end + end + + @spec workspace_path(Project.t(), Issue.t()) :: String.t() + def workspace_path(%Project{} = project, %Issue{} = issue) do + Path.join([ + Config.workspace_root(), + "projects", + safe_segment(project.name || project.id), + safe_segment(issue.identifier || issue.id) + ]) + end + + @spec issue_comment_rel_path() :: String.t() + def issue_comment_rel_path, do: @comment_rel_path + + @spec clone_details(Project.t(), keyword()) :: + {:ok, String.t(), String.t() | nil} | {:error, term()} + defp clone_details(%Project{} = project, opts) do + clone_source = + Keyword.get(opts, :clone_source) || + target_remote_url(project) || + project.repo_path + + origin_url = + Keyword.get(opts, :origin_url) || + target_remote_url(project) + + if not is_binary(clone_source) or String.trim(clone_source) == "" do + {:error, :missing_clone_source} + else + {:ok, clone_source, origin_url} + end + end + + @spec clone_repo(String.t(), String.t() | nil, String.t()) :: :ok | {:error, term()} + defp clone_repo(clone_source, origin_url, workspace) do + case System.cmd("git", ["clone", clone_source, workspace], stderr_to_stdout: true) do + {_output, 0} -> + maybe_set_origin_url(workspace, clone_source, origin_url) + + {output, status} -> + {:error, {:workspace_clone_failed, status, output}} + end + rescue + error -> {:error, {:workspace_clone_error, error}} + end + + @spec maybe_set_origin_url(String.t(), String.t(), String.t() | nil) :: :ok | {:error, term()} + defp maybe_set_origin_url(_workspace, _clone_source, nil), do: :ok + + defp maybe_set_origin_url(workspace, clone_source, origin_url) do + if clone_source == origin_url do + :ok + else + case System.cmd("git", ["-C", workspace, "remote", "set-url", "origin", origin_url], stderr_to_stdout: true) do + {_output, 0} -> :ok + {output, status} -> {:error, {:workspace_origin_set_failed, status, output}} + end + end + rescue + error -> {:error, {:workspace_origin_set_error, error}} + end + + @spec copy_git_identity(String.t(), String.t()) :: :ok + defp copy_git_identity(source_repo, workspace) do + Enum.each(["user.name", "user.email"], fn key -> + case read_git_config(source_repo, key) do + {:ok, value} -> + _ = System.cmd("git", ["-C", workspace, "config", key, value], stderr_to_stdout: true) + :ok + + _ -> + :ok + end + end) + + :ok + end + + @spec read_git_config(String.t(), String.t()) :: {:ok, String.t()} | :error + defp read_git_config(repo_path, key) do + case System.cmd("git", ["-C", repo_path, "config", key], stderr_to_stdout: true) do + {value, 0} -> + trimmed = String.trim(value) + if trimmed == "", do: :error, else: {:ok, trimmed} + + _ -> + :error + end + rescue + _error -> + :error + end + + @spec initialize_workspace_files(String.t()) :: :ok | {:error, term()} + defp initialize_workspace_files(workspace) do + comment_file = Path.join(workspace, @comment_rel_path) + + comment_file + |> Path.dirname() + |> File.mkdir_p!() + + File.write!(comment_file, "") + :ok + rescue + error -> {:error, {:workspace_init_failed, error}} + end + + @spec target_remote_url(Project.t()) :: String.t() | nil + defp target_remote_url(%Project{} = project) do + case matching_remote_url(project) do + {:ok, url} -> + url + + :error -> + fallback_remote_url(project) + end + end + + @spec matching_remote_url(Project.t()) :: {:ok, String.t()} | :error + defp matching_remote_url(%Project{} = project) do + project.repo_path + |> list_remote_urls() + |> Enum.find(&remote_matches_project?(&1, project)) + |> case do + nil -> :error + url -> {:ok, url} + end + end + + @spec list_remote_urls(String.t()) :: [String.t()] + defp list_remote_urls(repo_path) do + case System.cmd("git", ["-C", repo_path, "config", "--get-regexp", "^remote\\..*\\.url$"], stderr_to_stdout: true) do + {output, 0} -> + output + |> String.split("\n", trim: true) + |> Enum.flat_map(&parse_remote_url_line/1) + + _ -> + [] + end + rescue + _error -> + [] + end + + @spec parse_remote_url_line(String.t()) :: [String.t()] + defp parse_remote_url_line(line) do + case String.split(line, " ", parts: 2) do + [_key, url] -> [String.trim(url)] + _ -> [] + end + end + + @spec remote_matches_project?(String.t(), Project.t()) :: boolean() + defp remote_matches_project?(url, %Project{provider: "github", provider_config: provider_config}) do + owner = Regex.escape(provider_config["owner"] || "") + repo = Regex.escape(provider_config["repo"] || "") + + owner != "" and repo != "" and + Regex.match?(~r/github\.com[:\/]#{owner}\/#{repo}(\.git)?$/i, url) + end + + defp remote_matches_project?(url, %Project{provider: "gitlab", provider_config: provider_config}) do + host = provider_config["base_url"] |> to_host() |> Regex.escape() + project_path = Regex.escape(provider_config["project_path"] || "") + + host != "" and project_path != "" and + Regex.match?(~r/#{host}[:\/]#{project_path}(\.git)?$/i, url) + end + + defp remote_matches_project?(_url, _project), do: false + + @spec fallback_remote_url(Project.t()) :: String.t() | nil + defp fallback_remote_url(%Project{provider: "github", provider_config: provider_config}) do + owner = provider_config["owner"] + repo = provider_config["repo"] + + if blank?(owner) or blank?(repo) do + nil + else + "git@github.com:#{owner}/#{repo}.git" + end + end + + defp fallback_remote_url(%Project{provider: "gitlab", provider_config: provider_config}) do + host = to_host(provider_config["base_url"]) + project_path = provider_config["project_path"] + + if blank?(host) or blank?(project_path) do + nil + else + "git@#{host}:#{project_path}.git" + end + end + + defp fallback_remote_url(_project), do: nil + + @spec to_host(String.t() | nil) :: String.t() | nil + defp to_host(nil), do: nil + + defp to_host(url) when is_binary(url) do + case URI.parse(url) do + %URI{host: host} when is_binary(host) and host != "" -> host + _ -> nil + end + end + + @spec validate_workspace_path(String.t()) :: :ok | {:error, term()} + defp validate_workspace_path(workspace) when is_binary(workspace) do + expanded_workspace = Path.expand(workspace) + root = Path.expand(Config.workspace_root()) + + cond do + expanded_workspace == root -> + {:error, {:workspace_equals_root, expanded_workspace, root}} + + String.starts_with?(expanded_workspace <> "/", root <> "/") -> + :ok + + true -> + {:error, {:workspace_outside_root, expanded_workspace, root}} + end + end + + @spec reset_workspace(String.t()) :: :ok + defp reset_workspace(workspace) do + File.rm_rf!(workspace) + File.mkdir_p!(Path.dirname(workspace)) + :ok + end + + @spec safe_segment(String.t() | nil) :: String.t() + defp safe_segment(value) do + value + |> Kernel.||("item") + |> String.replace(~r/[^a-zA-Z0-9._-]/, "_") + |> String.trim("_") + |> case do + "" -> "item" + safe -> safe + end + end + + @spec blank?(term()) :: boolean() + defp blank?(value) when is_binary(value), do: String.trim(value) == "" + defp blank?(_value), do: true +end diff --git a/elixir/lib/symphony_elixir/tracker/github/adapter.ex b/elixir/lib/symphony_elixir/tracker/github/adapter.ex index 9494fd5e2..c6aee4c4c 100644 --- a/elixir/lib/symphony_elixir/tracker/github/adapter.ex +++ b/elixir/lib/symphony_elixir/tracker/github/adapter.ex @@ -15,6 +15,9 @@ defmodule SymphonyElixir.Tracker.GitHub.Adapter do @spec create_comment(Project.t(), String.t(), String.t()) :: :ok | {:error, term()} def create_comment(%Project{} = project, issue_id, body), do: Client.create_comment(project, issue_id, body) + @spec replace_labels(Project.t(), String.t(), [String.t()]) :: :ok | {:error, term()} + def replace_labels(%Project{} = project, issue_id, labels), do: Client.replace_labels(project, issue_id, labels) + @spec update_issue_state(Project.t(), String.t(), String.t()) :: :ok | {:error, term()} def update_issue_state(%Project{} = project, issue_id, state_name), do: Client.update_issue_state(project, issue_id, state_name) diff --git a/elixir/lib/symphony_elixir/tracker/github/client.ex b/elixir/lib/symphony_elixir/tracker/github/client.ex index c71f66e13..918c2a2e7 100644 --- a/elixir/lib/symphony_elixir/tracker/github/client.ex +++ b/elixir/lib/symphony_elixir/tracker/github/client.ex @@ -38,6 +38,19 @@ defmodule SymphonyElixir.Tracker.GitHub.Client do end end + @spec replace_labels(Project.t(), String.t(), [String.t()]) :: :ok | {:error, term()} + def replace_labels(%Project{} = project, issue_id, labels) + when is_binary(issue_id) and is_list(labels) do + with {:ok, _token, headers, base_url, owner, repo} <- request_context(project), + {:ok, number} <- parse_issue_number(issue_id), + {:ok, _} <- + request(:patch, base_url, "/repos/#{owner}/#{repo}/issues/#{number}", headers, %{ + "labels" => labels + }) do + :ok + end + end + @spec update_issue_state(Project.t(), String.t(), String.t()) :: :ok | {:error, term()} def update_issue_state(%Project{} = project, issue_id, state_name) when is_binary(issue_id) and is_binary(state_name) do diff --git a/elixir/lib/symphony_elixir/tracker/gitlab/adapter.ex b/elixir/lib/symphony_elixir/tracker/gitlab/adapter.ex index f36b662a9..86b5fe599 100644 --- a/elixir/lib/symphony_elixir/tracker/gitlab/adapter.ex +++ b/elixir/lib/symphony_elixir/tracker/gitlab/adapter.ex @@ -15,6 +15,9 @@ defmodule SymphonyElixir.Tracker.GitLab.Adapter do @spec create_comment(Project.t(), String.t(), String.t()) :: :ok | {:error, term()} def create_comment(%Project{} = project, issue_id, body), do: Client.create_comment(project, issue_id, body) + @spec replace_labels(Project.t(), String.t(), [String.t()]) :: :ok | {:error, term()} + def replace_labels(%Project{} = project, issue_id, labels), do: Client.replace_labels(project, issue_id, labels) + @spec update_issue_state(Project.t(), String.t(), String.t()) :: :ok | {:error, term()} def update_issue_state(%Project{} = project, issue_id, state_name), do: Client.update_issue_state(project, issue_id, state_name) diff --git a/elixir/lib/symphony_elixir/tracker/gitlab/client.ex b/elixir/lib/symphony_elixir/tracker/gitlab/client.ex index 5b48847b1..debed2b2f 100644 --- a/elixir/lib/symphony_elixir/tracker/gitlab/client.ex +++ b/elixir/lib/symphony_elixir/tracker/gitlab/client.ex @@ -38,6 +38,17 @@ defmodule SymphonyElixir.Tracker.GitLab.Client do end end + @spec replace_labels(Project.t(), String.t(), [String.t()]) :: :ok | {:error, term()} + def replace_labels(%Project{} = project, issue_id, labels) + when is_binary(issue_id) and is_list(labels) do + with {:ok, headers, base_url, project_ref} <- request_context(project), + {:ok, type, iid} <- parse_issue_key(issue_id), + path <- update_path(project_ref, type, iid), + {:ok, _} <- request(:put, base_url, path, headers, %{"labels" => Enum.join(labels, ",")}) do + :ok + end + end + @spec update_issue_state(Project.t(), String.t(), String.t()) :: :ok | {:error, term()} def update_issue_state(%Project{} = project, issue_id, state_name) when is_binary(issue_id) and is_binary(state_name) do diff --git a/elixir/lib/symphony_elixir/tracker/router.ex b/elixir/lib/symphony_elixir/tracker/router.ex index 15707f258..b273354b3 100644 --- a/elixir/lib/symphony_elixir/tracker/router.ex +++ b/elixir/lib/symphony_elixir/tracker/router.ex @@ -21,6 +21,11 @@ defmodule SymphonyElixir.Tracker.Router do adapter(project).create_comment(project, issue_id, body) end + @spec replace_labels(Project.t(), String.t(), [String.t()]) :: :ok | {:error, term()} + def replace_labels(%Project{} = project, issue_id, labels) when is_list(labels) do + adapter(project).replace_labels(project, issue_id, labels) + end + @spec update_issue_state(Project.t(), String.t(), String.t()) :: :ok | {:error, term()} def update_issue_state(%Project{} = project, issue_id, state_name) do adapter(project).update_issue_state(project, issue_id, state_name) diff --git a/elixir/test/symphony_elixir/multi_project_agent_runner_test.exs b/elixir/test/symphony_elixir/multi_project_agent_runner_test.exs new file mode 100644 index 000000000..3a111f713 --- /dev/null +++ b/elixir/test/symphony_elixir/multi_project_agent_runner_test.exs @@ -0,0 +1,164 @@ +defmodule SymphonyElixir.MultiProjectAgentRunnerTest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.{ + MultiProjectAgentRunner, + MultiProjectPromptBuilder, + MultiProjectWorkspace, + Project + } + + alias SymphonyElixir.Tracker.Issue + + defmodule FakeAppServer do + def run(workspace, prompt, issue, opts) do + send(opts[:test_pid], {:fake_app_server_run, workspace, prompt, issue, opts}) + File.write!(opts[:issue_comment_path], "Agent says hi from #{issue.identifier}") + {:ok, %{result: :completed}} + end + end + + test "full agent runner clones the repo and returns the generated issue comment" do + {repo_path, workspace_root} = create_repo_fixture("runner") + + write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) + + {:ok, project} = + Project.new(%{ + "name" => "RunnerProject", + "repo_path" => repo_path, + "provider" => "github", + "mode" => "full_agent", + "provider_config" => %{ + "owner" => "RaistlinMuc", + "repo" => "symphony" + } + }) + + issue = issue_fixture(identifier: "#77", title: "Tell a joke", description: "Comment with a joke") + + assert {:ok, result} = + MultiProjectAgentRunner.run(project, issue, + app_server_module: FakeAppServer, + app_server_opts: [test_pid: self()], + workspace_opts: [clone_source: repo_path, origin_url: "git@github.com:RaistlinMuc/symphony.git"] + ) + + assert result.comment_body == "Agent says hi from #77" + assert result.summary == "Agent says hi from #77" + assert File.exists?(Path.join(result.workspace, ".git")) + assert File.read!(Path.join(result.workspace, "README.md")) =~ "fixture runner" + + assert_received {:fake_app_server_run, workspace, prompt, ^issue, opts} + assert workspace == result.workspace + assert opts[:issue_comment_path] == Path.join(result.workspace, ".symphony/issue_comment.md") + assert prompt =~ "Comment file:" + assert prompt =~ "Do not post issue comments directly" + assert prompt =~ "Tell a joke" + end + + test "workspace clone resets origin to the tracked remote url" do + {repo_path, workspace_root} = create_repo_fixture("workspace") + + write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) + + {:ok, project} = + Project.new(%{ + "name" => "WorkspaceProject", + "repo_path" => repo_path, + "provider" => "github", + "mode" => "full_agent", + "provider_config" => %{ + "owner" => "RaistlinMuc", + "repo" => "symphony" + } + }) + + issue = issue_fixture(identifier: "#88") + + assert {:ok, workspace} = + MultiProjectWorkspace.create_for_issue(project, issue, + clone_source: repo_path, + origin_url: "git@github.com:RaistlinMuc/symphony.git" + ) + + assert workspace.comment_file == Path.join(workspace.path, ".symphony/issue_comment.md") + assert File.read!(workspace.comment_file) == "" + + {origin_url, 0} = + System.cmd("git", ["-C", workspace.path, "remote", "get-url", "origin"], stderr_to_stdout: true) + + assert String.trim(origin_url) == "git@github.com:RaistlinMuc/symphony.git" + end + + test "prompt builder includes repo, issue, and comment file instructions" do + {:ok, project} = + Project.new(%{ + "name" => "PromptProject", + "repo_path" => "/tmp/prompt-project", + "provider" => "github", + "mode" => "full_agent", + "provider_config" => %{ + "owner" => "RaistlinMuc", + "repo" => "symphony" + } + }) + + issue = + issue_fixture( + identifier: "#99", + title: "Write a joke comment", + description: "Tell a joke on the issue", + labels: ["todo", "fun"] + ) + + prompt = + MultiProjectPromptBuilder.build_prompt(project, issue, "/tmp/workspace", comment_file: "/tmp/workspace/.symphony/issue_comment.md") + + assert prompt =~ "Repository: RaistlinMuc/symphony" + assert prompt =~ "Issue: #99 - Write a joke comment" + assert prompt =~ "Labels: todo, fun" + assert prompt =~ "/tmp/workspace/.symphony/issue_comment.md" + assert prompt =~ "`gh` CLI is available" + end + + defp create_repo_fixture(name) do + root = + Path.join( + System.tmp_dir!(), + "symphony-full-agent-#{name}-#{System.unique_integer([:positive])}" + ) + + repo_path = Path.join(root, "repo") + workspace_root = Path.join(root, "workspaces") + + File.mkdir_p!(repo_path) + File.write!(Path.join(repo_path, "README.md"), "fixture #{name}\n") + System.cmd("git", ["-C", repo_path, "init", "-b", "main"], stderr_to_stdout: true) + System.cmd("git", ["-C", repo_path, "config", "user.name", "Test User"], stderr_to_stdout: true) + System.cmd("git", ["-C", repo_path, "config", "user.email", "test@example.com"], stderr_to_stdout: true) + System.cmd("git", ["-C", repo_path, "add", "README.md"], stderr_to_stdout: true) + System.cmd("git", ["-C", repo_path, "commit", "-m", "initial"], stderr_to_stdout: true) + + on_exit(fn -> File.rm_rf(root) end) + + {repo_path, workspace_root} + end + + defp issue_fixture(attrs) do + attrs = Map.new(attrs) + + %Issue{ + id: Map.get(attrs, :id, "issue:77"), + identifier: Map.get(attrs, :identifier, "#42"), + title: Map.get(attrs, :title, "Test issue"), + description: Map.get(attrs, :description, "Issue body"), + state: Map.get(attrs, :state, "open"), + url: Map.get(attrs, :url, "https://example.test/issues/42"), + updated_at: Map.get(attrs, :updated_at, ~U[2026-03-06 01:00:00Z]), + branch_name: Map.get(attrs, :branch_name, nil), + labels: Map.get(attrs, :labels, ["todo"]), + source: Map.get(attrs, :source, :issue) + } + end +end diff --git a/elixir/test/symphony_elixir/multi_project_orchestrator_test.exs b/elixir/test/symphony_elixir/multi_project_orchestrator_test.exs new file mode 100644 index 000000000..5c658e4c1 --- /dev/null +++ b/elixir/test/symphony_elixir/multi_project_orchestrator_test.exs @@ -0,0 +1,66 @@ +defmodule SymphonyElixir.MultiProjectOrchestratorTest do + use ExUnit.Case + + alias SymphonyElixir.MultiProjectOrchestrator + alias SymphonyElixir.Tracker.Issue + + test "run_command_for_test executes successful commands" do + assert {:ok, "ok"} = + MultiProjectOrchestrator.run_command_for_test("printf 'ok'", File.cwd!(), 5_000) + end + + test "run_command_for_test returns exit status failures" do + assert {:error, {:exit_status, 7, output}} = + MultiProjectOrchestrator.run_command_for_test("echo fail && exit 7", File.cwd!(), 5_000) + + assert output =~ "fail" + end + + test "issue_signature_for_test ignores updated_at changes and label order" do + issue_a = issue_fixture(updated_at: ~U[2026-03-06 01:00:00Z], labels: ["todo", "bug"]) + issue_b = issue_fixture(updated_at: ~U[2026-03-06 01:10:00Z], labels: ["bug", "todo"]) + + assert MultiProjectOrchestrator.issue_signature_for_test(issue_a) == + MultiProjectOrchestrator.issue_signature_for_test(issue_b) + end + + test "issue_signature_for_test changes when issue content changes" do + issue_a = issue_fixture(description: "first body") + issue_b = issue_fixture(description: "second body") + + refute MultiProjectOrchestrator.issue_signature_for_test(issue_a) == + MultiProjectOrchestrator.issue_signature_for_test(issue_b) + end + + test "track_issues_for_test drops stale issues that are no longer filtered" do + stale_issue = issue_fixture(id: "issue:1", title: "stale") + current_issue = issue_fixture(id: "issue:2", title: "current") + + existing = %{ + stale_issue.id => MultiProjectOrchestrator.issue_signature_for_test(stale_issue) + } + + tracked = MultiProjectOrchestrator.track_issues_for_test(existing, [current_issue]) + + assert tracked == %{ + current_issue.id => MultiProjectOrchestrator.issue_signature_for_test(current_issue) + } + end + + defp issue_fixture(attrs) do + attrs = Map.new(attrs) + + %Issue{ + id: Map.get(attrs, :id, "issue:42"), + identifier: Map.get(attrs, :identifier, "#42"), + title: Map.get(attrs, :title, "Test issue"), + description: Map.get(attrs, :description, "Issue body"), + state: Map.get(attrs, :state, "open"), + url: Map.get(attrs, :url, "https://example.test/issues/42"), + updated_at: Map.get(attrs, :updated_at, ~U[2026-03-06 01:00:00Z]), + branch_name: Map.get(attrs, :branch_name, nil), + labels: Map.get(attrs, :labels, ["todo"]), + source: Map.get(attrs, :source, :issue) + } + end +end