diff --git a/assets/css/app.css b/assets/css/app.css index beba5c6..282945d 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -193,3 +193,35 @@ dl.data-list dd { padding: 0; margin-left: auto; } + +.run { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem 1rem; + margin-bottom: 1rem; +} + +.run h3 { + margin: 0; + font-size: 1rem; +} + +.run h3[data-status="failure"] { + color: var(--error); +} + +.run p { + margin: 0.25rem 0; + color: var(--muted); + font-size: 0.875rem; +} + +.run pre { + overflow-x: auto; + margin: 0.5rem 0 0; + padding: 0.5rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.8125rem; +} diff --git a/lib/git_sync/application.ex b/lib/git_sync/application.ex index 615e175..7ecee3a 100644 --- a/lib/git_sync/application.ex +++ b/lib/git_sync/application.ex @@ -40,9 +40,7 @@ defmodule GitSync.Application do end defp start_runners do - unless Application.get_env(:git_sync, :start_runners) == false do - GitSync.Sync.start_enabled() - end + if GitSync.Sync.runners_enabled?(), do: GitSync.Sync.start_enabled() end defp skip_migrations?() do diff --git a/lib/git_sync/connections.ex b/lib/git_sync/connections.ex index 7279d06..b2c5cc4 100644 --- a/lib/git_sync/connections.ex +++ b/lib/git_sync/connections.ex @@ -8,6 +8,10 @@ defmodule GitSync.Connections do alias GitSync.Connection alias GitSync.Repo + def list, do: Repo.all(from c in Connection, order_by: [asc: c.id]) + + def get_by_id(id), do: Repo.get(Connection, id) + def get(kind), do: Repo.one(from c in Connection, where: c.kind == ^kind) def forgejo, do: get(:forgejo) diff --git a/lib/git_sync/mappings.ex b/lib/git_sync/mappings.ex index b956d92..9756d5f 100644 --- a/lib/git_sync/mappings.ex +++ b/lib/git_sync/mappings.ex @@ -11,7 +11,19 @@ defmodule GitSync.Mappings do @secret_bytes 32 - def get(id), do: Repo.get(Mapping, id) + def get(id) do + Mapping + |> Repo.get(id) + |> Repo.preload([:source_connection, :destination_connection]) + end + + @doc """ + Every mapping, newest first, with the forges at both ends. + """ + def list do + Repo.all(from m in Mapping, order_by: [desc: m.id]) + |> Repo.preload([:source_connection, :destination_connection]) + end def enabled, do: Repo.all(from m in Mapping, where: m.enabled) @@ -24,6 +36,14 @@ defmodule GitSync.Mappings do |> Repo.insert() end + def update(%Mapping{} = mapping, attrs) do + mapping + |> Mapping.changeset(attrs) + |> Repo.update() + end + + def delete(%Mapping{} = mapping), do: Repo.delete(mapping) + @doc """ Asks the source forge to notify `base_url` when the repository moves. A forge with no webhooks leaves the mapping on its timer, which is the only path that diff --git a/lib/git_sync/sync.ex b/lib/git_sync/sync.ex index ed2364b..cf30e2e 100644 --- a/lib/git_sync/sync.ex +++ b/lib/git_sync/sync.ex @@ -48,6 +48,24 @@ defmodule GitSync.Sync do start_runner(mapping, opts) end + @doc """ + Brings the runner for a mapping in line with the row: a mapping that is + switched on gets a runner on its current interval, one that is switched off + gets none. + """ + def reconcile(mapping, opts \\ []) + + def reconcile(%Mapping{enabled: true} = mapping, opts) do + if runners_enabled?(), do: restart_runner(mapping, opts), else: :ok + end + + def reconcile(%Mapping{} = mapping, _opts), do: stop_runner(mapping.id) + + @doc """ + Whether this node runs syncs at all. Test and console runs switch it off. + """ + def runners_enabled?, do: Application.get_env(:git_sync, :start_runners) != false + def stop_runner(mapping_id) do case whereis(mapping_id) do nil -> :ok diff --git a/lib/git_sync_web/components/layouts.ex b/lib/git_sync_web/components/layouts.ex index 775677f..02ee7c9 100644 --- a/lib/git_sync_web/components/layouts.ex +++ b/lib/git_sync_web/components/layouts.ex @@ -36,6 +36,7 @@ defmodule GitSyncWeb.Layouts do
git-sync diff --git a/lib/git_sync_web/controllers/mapping_controller.ex b/lib/git_sync_web/controllers/mapping_controller.ex new file mode 100644 index 0000000..60d3896 --- /dev/null +++ b/lib/git_sync_web/controllers/mapping_controller.ex @@ -0,0 +1,176 @@ +defmodule GitSyncWeb.MappingController do + use GitSyncWeb, :controller + + import Phoenix.Component, only: [to_form: 1] + + alias GitSync.Connection + alias GitSync.Connections + alias GitSync.Forge + alias GitSync.Mapping + alias GitSync.Mappings + alias GitSync.Runs + alias GitSync.Sync + alias GitSyncWeb.MappingHTML + alias GitSyncWeb.SSE + + def index(conn, _params), do: render_index(conn, Mapping.changeset(%Mapping{}, %{})) + + def create(conn, %{"mapping" => params}) do + case Mappings.create(params) do + {:ok, mapping} -> + Sync.reconcile(mapping) + + conn + |> register_webhook(mapping) + |> redirect(to: ~p"/mappings/#{mapping}") + + {:error, changeset} -> + render_index(conn, changeset) + end + end + + def show(conn, %{"id" => id}) do + mapping = Mappings.get(id) + + render(conn, :show, mapping: mapping, runs: Runs.list(mapping.id)) + end + + def update(conn, %{"id" => id, "mapping" => params}) do + mapping = Mappings.get(id) + + case Mappings.update(mapping, params) do + {:ok, mapping} -> + Sync.reconcile(mapping) + + conn + |> put_flash(:info, "Mapping updated.") + |> redirect(to: ~p"/mappings/#{mapping}") + + {:error, _changeset} -> + conn + |> put_flash(:error, "That change was refused.") + |> redirect(to: ~p"/mappings/#{mapping}") + end + end + + def sync(conn, %{"id" => id}) do + mapping = Mappings.get(id) + Sync.sync_now(mapping.id) + + conn + |> put_flash(:info, "Syncing now.") + |> redirect(to: ~p"/mappings/#{mapping}") + end + + def delete(conn, %{"id" => id}) do + mapping = Mappings.get(id) + Sync.stop_runner(mapping.id) + {:ok, _mapping} = Mappings.delete(mapping) + + conn + |> put_flash(:info, "Mapping removed.") + |> redirect(to: ~p"/mappings") + end + + @doc """ + Repaints both repository pickers for the forges the form now names. + """ + def repos(conn, params) do + signals = JSON.decode!(params["datastar"] || "{}") + form = to_form(Mapping.changeset(%Mapping{}, %{})) + + conn + |> SSE.open() + |> patch_repo_field( + "source-repo-field", + form[:source_repo], + "Source repository", + signals["source_connection_id"] + ) + |> patch_repo_field( + "destination-repo-field", + form[:destination_repo], + "Destination repository", + signals["destination_connection_id"] + ) + end + + @doc """ + Streams run updates for one mapping until the browser goes away. + """ + def events(conn, %{"id" => id}) do + mapping = Mappings.get(id) + :ok = Runs.subscribe(mapping.id) + + conn + |> SSE.open() + |> SSE.stream(&handle_run/2) + end + + @doc """ + The `events/2` stream handler: every run update repaints the run list. + """ + def handle_run({:run, run}, conn) do + case SSE.patch_elements(conn, runs_markup(run.mapping_id)) do + {:ok, conn} -> {:cont, conn} + {:error, _reason} -> {:halt, conn} + end + end + + defp patch_repo_field(conn, id, field, label, connection_id) do + markup = + markup( + MappingHTML.repo_field(%{ + id: id, + field: field, + label: label, + repos: list_repos(connection_id) + }) + ) + + case SSE.patch_elements(conn, markup) do + {:ok, conn} -> conn + {:error, _reason} -> conn + end + end + + defp list_repos(connection_id) when connection_id in [nil, ""], do: nil + + defp list_repos(connection_id) do + case Connections.get_by_id(connection_id) do + %Connection{} = connection -> Forge.list_repos(connection) + nil -> nil + end + end + + defp runs_markup(mapping_id), do: markup(MappingHTML.runs(%{runs: Runs.list(mapping_id)})) + + defp markup(rendered) do + rendered + |> Phoenix.HTML.Safe.to_iodata() + |> IO.iodata_to_binary() + end + + defp render_index(conn, changeset) do + connections = Connections.list() + + render(conn, :index, + mappings: Mappings.list(), + form: to_form(changeset), + connection_options: Enum.map(connections, &connection_option/1), + source_repos: list_repos(Ecto.Changeset.get_field(changeset, :source_connection_id)), + destination_repos: + list_repos(Ecto.Changeset.get_field(changeset, :destination_connection_id)) + ) + end + + defp connection_option(%Connection{} = connection), + do: {"#{connection.kind} — #{connection.base_url}", connection.id} + + defp register_webhook(conn, %Mapping{} = mapping) do + case Mappings.register_webhook(mapping, url(~p"/")) do + {:ok, _mapping} -> put_flash(conn, :info, "Mapping added.") + {:error, reason} -> put_flash(conn, :error, "Mapping added, but #{reason}") + end + end +end diff --git a/lib/git_sync_web/controllers/mapping_html.ex b/lib/git_sync_web/controllers/mapping_html.ex new file mode 100644 index 0000000..f2beb16 --- /dev/null +++ b/lib/git_sync_web/controllers/mapping_html.ex @@ -0,0 +1,88 @@ +defmodule GitSyncWeb.MappingHTML do + @moduledoc """ + Mapping management and run history pages. + """ + use GitSyncWeb, :html + + embed_templates "mapping_html/*" + + @doc """ + A repository picker for one end of a mapping. A forge that can be listed + gives a select; anything else falls back to a typed `owner/name`. + """ + attr :id, :string, required: true + attr :field, Phoenix.HTML.FormField, required: true + attr :label, :string, required: true + attr :repos, :any, required: true, doc: "`Forge.list_repos/1` result, or nil for no forge yet" + + def repo_field(assigns) do + ~H""" +
+ <.repo_input field={@field} label={@label} repos={@repos} /> +
+ """ + end + + defp repo_input(%{repos: {:ok, repos}} = assigns) do + assigns = assign(assigns, :options, Enum.map(repos, & &1.full_name)) + + ~H""" + <.input + field={@field} + type="select" + label={@label} + options={@options} + prompt="Choose a repository" + /> + """ + end + + defp repo_input(%{repos: nil} = assigns) do + ~H""" + <.input + field={@field} + type="select" + label={@label} + options={[]} + prompt="Choose a forge first" + disabled + /> + """ + end + + defp repo_input(assigns) do + ~H""" + <.input + field={@field} + label={@label} + placeholder="adam/git-sync" + autocomplete="off" + data-1p-ignore + data-lpignore="true" + /> +

Could not list repositories: {elem(@repos, 1)}

+ """ + end + + @doc """ + The run history of one mapping, patched into the page as runs happen. + """ + attr :runs, :list, required: true + + def runs(assigns) do + ~H""" +
+

No runs yet.

+ +
+

{run.status}

+

+ Started {run.started_at}, finished {run.finished_at} +

+

Pushed {Enum.join(run.refs_pushed, ", ")}

+
{run.log}
+
+
+ """ + end +end diff --git a/lib/git_sync_web/controllers/mapping_html/index.html.heex b/lib/git_sync_web/controllers/mapping_html/index.html.heex new file mode 100644 index 0000000..d302d39 --- /dev/null +++ b/lib/git_sync_web/controllers/mapping_html/index.html.heex @@ -0,0 +1,58 @@ + + <.header> + Mappings + <:subtitle>Source repositories mirrored onto destinations. + + + <.table id="mappings" rows={@mappings}> + <:col :let={mapping} label="Source"> + {mapping.source_repo} on {mapping.source_connection.base_url} + + <:col :let={mapping} label="Destination"> + {mapping.destination_repo} on {mapping.destination_connection.base_url} + + <:col :let={mapping} label="Every">{mapping.interval_seconds}s + <:col :let={mapping} label="State">{(mapping.enabled && "on") || "off"} + <:action :let={mapping}> + <.link href={~p"/mappings/#{mapping}"}>Runs + + + + <.header>Add a mapping + + <.form for={@form} id="mapping-form" action={~p"/mappings"}> + <.input + field={@form[:source_connection_id]} + type="select" + label="Source forge" + options={@connection_options} + prompt="Choose a forge" + data-bind="source_connection_id" + data-on:change={"@get('#{~p"/mappings/repos"}')"} + /> + <.repo_field + id="source-repo-field" + field={@form[:source_repo]} + label="Source repository" + repos={@source_repos} + /> + <.input + field={@form[:destination_connection_id]} + type="select" + label="Destination forge" + options={@connection_options} + prompt="Choose a forge" + data-bind="destination_connection_id" + data-on:change={"@get('#{~p"/mappings/repos"}')"} + /> + <.repo_field + id="destination-repo-field" + field={@form[:destination_repo]} + label="Destination repository" + repos={@destination_repos} + /> + <.input field={@form[:interval_seconds]} type="number" label="Interval in seconds" /> + + <.button id="add-mapping" type="submit">Add mapping + + diff --git a/lib/git_sync_web/controllers/mapping_html/show.html.heex b/lib/git_sync_web/controllers/mapping_html/show.html.heex new file mode 100644 index 0000000..e0e11e0 --- /dev/null +++ b/lib/git_sync_web/controllers/mapping_html/show.html.heex @@ -0,0 +1,30 @@ + + <.header> + {@mapping.source_repo} → {@mapping.destination_repo} + <:subtitle> + {@mapping.source_connection.base_url} to {@mapping.destination_connection.base_url}, + every {@mapping.interval_seconds}s. + + <:actions> + <.button href={~p"/mappings/#{@mapping}/sync"} method="post">Sync now + <.form for={%{}} action={~p"/mappings/#{@mapping}"} method="put"> + + <.button id="toggle-mapping" type="submit" variant="secondary"> + {(@mapping.enabled && "Switch off") || "Switch on"} + + + <.button + href={~p"/mappings/#{@mapping}"} + method="delete" + variant="secondary" + data-confirm="Remove this mapping?" + > + Remove + + + + +
+ <.runs runs={@runs} /> +
+
diff --git a/lib/git_sync_web/router.ex b/lib/git_sync_web/router.ex index 84a3f8b..dfa8e8f 100644 --- a/lib/git_sync_web/router.ex +++ b/lib/git_sync_web/router.ex @@ -48,6 +48,14 @@ defmodule GitSyncWeb.Router do pipe_through [:browser, :configured, :authenticated] get "/", PageController, :home + get "/mappings", MappingController, :index + post "/mappings", MappingController, :create + get "/mappings/repos", MappingController, :repos + get "/mappings/:id", MappingController, :show + put "/mappings/:id", MappingController, :update + delete "/mappings/:id", MappingController, :delete + post "/mappings/:id/sync", MappingController, :sync + get "/mappings/:id/events", MappingController, :events get "/connections", ConnectionController, :index post "/connections/github", GithubController, :create delete "/connections/github", GithubController, :delete diff --git a/test/git_sync/mappings_test.exs b/test/git_sync/mappings_test.exs index 8d81ebf..dbad93a 100644 --- a/test/git_sync/mappings_test.exs +++ b/test/git_sync/mappings_test.exs @@ -29,6 +29,41 @@ defmodule GitSync.MappingsTest do end end + describe "list/0" do + test "returns the mappings with their connections", %{forgejo: forgejo, github: github} do + {:ok, mapping} = Mappings.create(attrs(forgejo, github)) + + assert [listed] = Mappings.list() + assert listed.id == mapping.id + assert listed.source_connection.base_url == "https://forge.test" + assert listed.destination_connection.base_url == "https://github.com" + end + end + + describe "update/2" do + test "switches a mapping off", %{forgejo: forgejo, github: github} do + {:ok, mapping} = Mappings.create(attrs(forgejo, github)) + + assert {:ok, mapping} = Mappings.update(mapping, %{enabled: false}) + refute mapping.enabled + end + + test "reports an invalid change", %{forgejo: forgejo, github: github} do + {:ok, mapping} = Mappings.create(attrs(forgejo, github)) + + assert {:error, %Ecto.Changeset{}} = Mappings.update(mapping, %{interval_seconds: 1}) + end + end + + describe "delete/1" do + test "removes the mapping", %{forgejo: forgejo, github: github} do + {:ok, mapping} = Mappings.create(attrs(forgejo, github)) + + assert {:ok, _mapping} = Mappings.delete(mapping) + assert is_nil(Mappings.get(mapping.id)) + end + end + describe "register_webhook/2" do test "records the hook the source forge created", %{forgejo: forgejo, github: github} do Req.Test.stub(GitSync.Http, fn conn -> diff --git a/test/git_sync/sync_test.exs b/test/git_sync/sync_test.exs index 132008e..0db9c3a 100644 --- a/test/git_sync/sync_test.exs +++ b/test/git_sync/sync_test.exs @@ -59,10 +59,12 @@ defmodule GitSync.SyncTest do test "stopping a runner leaves nothing behind", %{sync_fun: sync_fun, connections: connections} do mapping = mapping(connections) - {:ok, _} = Sync.start_runner(mapping, sync_fun: sync_fun) + {:ok, pid} = Sync.start_runner(mapping, sync_fun: sync_fun) + ref = Process.monitor(pid) :ok = Sync.stop_runner(mapping.id) + assert_receive {:DOWN, ^ref, :process, ^pid, _reason} assert Sync.running() == [] end @@ -104,6 +106,35 @@ defmodule GitSync.SyncTest do assert Sync.running() == [mapping.id] end + describe "reconcile/2" do + setup do + Application.put_env(:git_sync, :start_runners, true) + on_exit(fn -> Application.put_env(:git_sync, :start_runners, false) end) + end + + test "gives a switched-on mapping a runner", %{sync_fun: sync_fun, connections: connections} do + mapping = mapping(connections) + + {:ok, pid} = Sync.reconcile(mapping, sync_fun: sync_fun) + + assert Sync.whereis(mapping.id) == pid + end + + test "takes the runner away from a switched-off mapping", %{ + sync_fun: sync_fun, + connections: connections + } do + mapping = mapping(connections) + {:ok, pid} = Sync.start_runner(mapping, sync_fun: sync_fun) + ref = Process.monitor(pid) + + :ok = Sync.reconcile(%{mapping | enabled: false}, sync_fun: sync_fun) + + assert_receive {:DOWN, ^ref, :process, ^pid, _reason} + refute mapping.id in Sync.running() + end + end + defp mapping(connections, overrides \\ []) do attrs = Enum.into(overrides, %{ diff --git a/test/git_sync_web/controllers/mapping_controller_test.exs b/test/git_sync_web/controllers/mapping_controller_test.exs new file mode 100644 index 0000000..95d6e78 --- /dev/null +++ b/test/git_sync_web/controllers/mapping_controller_test.exs @@ -0,0 +1,241 @@ +defmodule GitSyncWeb.MappingControllerTest do + use GitSyncWeb.ConnCase + + alias GitSync.Connection + alias GitSync.Mapping + alias GitSync.Mappings + alias GitSync.Repo + alias GitSync.Run + alias GitSync.Sync + alias GitSyncWeb.MappingController + alias GitSyncWeb.SSE + + setup :configure_forgejo + + setup %{conn: conn, connection: connection} do + {:ok, forgejo} = GitSync.Connections.record_login(connection, "alice", "tok") + github = Repo.insert!(%Connection{kind: :github, base_url: "https://github.com", token: "t"}) + + root = Path.join(System.tmp_dir!(), "git-sync-#{System.unique_integer([:positive])}") + Application.put_env(:git_sync, :workspace_root, root) + + on_exit(fn -> + Enum.each(Sync.running(), &Sync.stop_runner/1) + File.rm_rf!(root) + end) + + %{ + conn: init_test_session(conn, %{"operator" => "alice"}), + forgejo: forgejo, + github: github + } + end + + describe "index" do + test "lists the mappings", %{conn: conn} = context do + mapping(context) + + html = html_response(get(conn, ~p"/mappings"), 200) + + assert html =~ "adam/git-sync" + assert html =~ "adam/mirror" + end + + test "offers the connections as sources and destinations", %{conn: conn} do + html = html_response(get(conn, ~p"/mappings"), 200) + + assert html =~ "https://forge.test" + assert html =~ "https://github.com" + end + + test "asks for a forge before a repository, with nothing to autofill", %{conn: conn} do + html = html_response(get(conn, ~p"/mappings"), 200) + + assert html =~ "data-on:change=" + assert html =~ "/mappings/repos" + assert html =~ ~s(