9.1 KiB · text 5af55d9
defmodule GitGudWeb.PrLive.New do
use GitGudWeb, :live_view
alias GitGud.Federation.Outbound
alias GitGud.Git
alias GitGud.PullRequests
alias GitGud.PullRequests.PullRequest
alias GitGud.Repositories
alias GitGud.Repositories.Storage
@impl true
def mount(%{"owner" => owner, "name" => name} = params, _session, socket) do
repo =
Repositories.get_repository_by_path!(owner, name)
|> GitGud.Repo.preload([:owner, :organization])
# Source-repo candidates: this repo + any fork of it the user can
# read. When viewing a fork, "this" is the fork — and the parent
# is included so you can open a PR back upstream.
source_candidates = source_candidates(repo, socket.assigns.current_scope)
source_repo_id = parse_int(params["source_repository_id"]) || repo.id
initial_source = Enum.find(source_candidates, &(&1.id == source_repo_id)) || repo
attrs = %{
"source_ref" => params["source"] || nil,
"target_ref" => params["target"] || repo.default_branch,
"source_repository_id" => Integer.to_string(initial_source.id)
}
{:ok,
socket
|> assign(:repo, repo)
|> assign(:handle, Storage.repo_handle(repo))
|> GitGudWeb.RepoLive.Header.assign_chrome(repo)
|> assign(:source_candidates, source_candidates)
|> assign(:source_repo, initial_source)
|> assign(:source_branches, branches_of(initial_source))
|> assign(:target_branches, branches_of(repo))
|> assign_form(PullRequest.create_changeset(%PullRequest{}, attrs))
|> assign(:remote_target_url, "")
|> assign(:remote_mode?, false)
|> assign(:page_title, "New pull request")}
end
defp source_candidates(repo, scope) do
forks = Repositories.list_forks(repo)
parent =
case repo do
%{parent_repository_id: nil} ->
[]
%{parent_repository_id: pid} ->
[Repositories.get_repository!(pid) |> GitGud.Repo.preload([:owner, :organization])]
end
([repo] ++ parent ++ forks)
|> Enum.uniq_by(& &1.id)
|> Enum.filter(&visible_to?(scope, &1))
end
defp visible_to?(_scope, %{visibility: vis}) when vis in ["public", "internal"], do: true
defp visible_to?(%{user: %{id: uid}}, %{owner_id: uid}), do: true
defp visible_to?(_, _), do: false
defp branches_of(repo) do
{:ok, refs} = Repositories.list_refs(repo)
Enum.filter(refs, &(&1.ref_type == "branch"))
end
defp parse_int(nil), do: nil
defp parse_int(s) when is_binary(s) do
case Integer.parse(s) do
{n, _} -> n
_ -> nil
end
end
defp parse_int(n) when is_integer(n), do: n
defp assign_form(socket, cs), do: assign(socket, :form, to_form(cs))
@impl true
def handle_event("validate", %{"pull_request" => attrs}, socket) do
new_source_id = parse_int(attrs["source_repository_id"]) || socket.assigns.source_repo.id
new_source =
Enum.find(socket.assigns.source_candidates, &(&1.id == new_source_id)) ||
socket.assigns.source_repo
cs = %PullRequest{} |> PullRequest.create_changeset(attrs) |> Map.put(:action, :validate)
{:noreply,
socket
|> assign(:source_repo, new_source)
|> assign(:source_branches, branches_of(new_source))
|> assign_form(cs)}
end
def handle_event("toggle_remote", _params, socket) do
{:noreply, assign(socket, :remote_mode?, not socket.assigns.remote_mode?)}
end
def handle_event("remote_target_url", %{"value" => v}, socket) do
{:noreply, assign(socket, :remote_target_url, v)}
end
def handle_event("save", %{"pull_request" => attrs}, socket) do
user = socket.assigns.current_scope.user
if socket.assigns.remote_mode? do
offer_to_remote(socket, user, attrs)
else
open_local_pr(socket, user, attrs)
end
end
defp open_local_pr(socket, user, attrs) do
case PullRequests.create_pull_request(socket.assigns.repo, user, attrs) do
{:ok, pr} ->
{:noreply,
socket
|> put_flash(:info, "Pull request ##{pr.number} opened.")
|> push_navigate(
to: ~p"/r/#{socket.assigns.handle}/#{socket.assigns.repo.name}/pulls/#{pr.number}"
)}
{:error, %Ecto.Changeset{} = cs} ->
{:noreply, assign_form(socket, cs)}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Could not open PR: #{inspect(reason)}")}
end
end
defp offer_to_remote(socket, user, attrs) do
source_repo = socket.assigns.source_repo
target_url = String.trim(socket.assigns.remote_target_url)
with {:nonblank, true} <- {:nonblank, target_url != ""},
{:ok, head_sha} <- Repositories.resolve(source_repo, attrs["source_ref"]) do
params = %{
target_repo_url: target_url,
source_branch: attrs["source_ref"],
target_branch: attrs["target_ref"],
head_hex: Git.to_hex(head_sha),
title: attrs["title"],
body: attrs["body"]
}
case Outbound.offer_pull_request(user, source_repo, params) do
{:ok, _activity} ->
{:noreply,
socket
|> put_flash(:info, "Offer queued for delivery to #{target_url}.")
|> push_navigate(
to: ~p"/r/#{socket.assigns.handle}/#{socket.assigns.repo.name}/pulls"
)}
{:error, :target_not_a_repository} ->
{:noreply,
put_flash(socket, :error, "That actor isn't a Repository — can't accept a PR there.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Could not send offer: #{inspect(reason)}")}
end
else
{:nonblank, _} ->
{:noreply, put_flash(socket, :error, "Enter the remote repository's AP actor URL.")}
{:error, :no_such_ref} ->
{:noreply, put_flash(socket, :error, "Source branch doesn't exist.")}
_ ->
{:noreply, put_flash(socket, :error, "Could not resolve source ref.")}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="space-y-4 max-w-2xl">
<GitGudWeb.RepoLive.Header.header
repo={@repo}
handle={@handle}
current_scope={@current_scope}
has_packages?={@has_packages?}
can_admin?={@can_admin?}
latest_run={@latest_run}
/>
<h1 class="text-xl font-semibold">
New pull request
<%= if @remote_mode? do %>
<span class="opacity-50">to</span>
<span class="badge badge-accent">federated</span>
<% end %>
</h1>
<p class="text-xs opacity-70">
<button type="button" phx-click="toggle_remote" class="link link-hover">
<%= if @remote_mode? do %>
← Back to opening locally
<% else %>
Send to a remote repository (ForgeFed Offer) →
<% end %>
</button>
</p>
<div :if={@remote_mode?} class="space-y-2 border border-base-300 rounded p-3">
<label class="text-sm font-semibold">Remote repository AP URL</label>
<input
type="text"
value={@remote_target_url}
phx-keyup="remote_target_url"
phx-debounce="200"
placeholder="https://other.host/r/them/proj"
class="input input-sm input-bordered w-full font-mono"
autocomplete="off"
/>
<p class="text-xs opacity-60">
The remote will fetch your branch directly. No local PR row is
created — status lives on the receiving instance.
</p>
</div>
<.form for={@form} id="new-pr-form" phx-change="validate" phx-submit="save" class="space-y-3">
<.input
:if={length(@source_candidates) > 1}
field={@form[:source_repository_id]}
label="Source repository"
type="select"
options={
Enum.map(@source_candidates, fn r -> {repo_label(r), Integer.to_string(r.id)} end)
}
/>
<.input
field={@form[:source_ref]}
label="Source branch"
type="select"
prompt="— choose —"
options={branch_options(@source_branches)}
/>
<%= if @remote_mode? do %>
<.input
field={@form[:target_ref]}
label="Target branch (on the remote)"
type="text"
placeholder="main"
/>
<% else %>
<.input
field={@form[:target_ref]}
label="Target branch"
type="select"
options={branch_options(@target_branches)}
/>
<% end %>
<.input field={@form[:title]} label="Title" required />
<.input field={@form[:body]} label="Description" type="textarea" rows="6" />
<button type="submit" class="btn btn-primary">
<%= if @remote_mode?, do: "Send offer", else: "Open PR" %>
</button>
</.form>
</div>
</Layouts.app>
"""
end
defp branch_options(branches) do
Enum.map(branches, fn b ->
short = String.replace_prefix(b.name, "refs/heads/", "")
{short, short}
end)
end
defp repo_label(repo) do
Storage.repo_handle(repo) <> "/" <> repo.name
end
end