defmodule GitGudWeb.PrLive.Show do
use GitGudWeb, :live_view
alias GitGud.Git
alias GitGud.Labels
alias GitGud.PullRequests
alias GitGud.PullRequests.PrComment
alias GitGud.PullRequests.PrReview
alias GitGud.Repositories
alias GitGud.Repositories.Storage
@impl true
def mount(%{"owner" => owner, "name" => name, "number" => num}, _session, socket) do
repo =
Repositories.get_repository_by_path!(owner, name)
|> GitGud.Repo.preload([:owner, :organization])
pr = PullRequests.get_pull_request!(repo, String.to_integer(num))
# Probe mergeability lazily on view. Cheap if cache hits.
{:ok, _} = maybe_probe(pr)
pr = PullRequests.get_pull_request!(repo, pr.number)
{:ok, stats} = Repositories.get_diff_stats(repo, pr.base_sha, pr.head_sha)
diff_files = load_diff(repo, pr)
source_repo =
if pr.source_repository_id && pr.source_repository_id != repo.id do
Repositories.get_repository!(pr.source_repository_id)
|> GitGud.Repo.preload([:owner, :organization])
else
repo
end
source_actor =
if pr.source_actor_id do
GitGud.Repo.get(GitGud.Federation.Actor, pr.source_actor_id)
end
{:ok,
socket
|> assign(:repo, repo)
|> assign(:handle, Storage.repo_handle(repo))
|> GitGudWeb.RepoLive.Header.assign_chrome(repo)
|> assign(:pr, pr)
|> assign(:source_repo, source_repo)
|> assign(:source_handle, Storage.repo_handle(source_repo))
|> assign(:cross_repo?, source_repo.id != repo.id)
|> assign(:source_actor, source_actor)
|> assign(:federated?, not is_nil(source_actor))
|> assign(:stats, stats)
|> assign(:diff_files, diff_files)
|> assign(:loaded_diff_idx, GitGudWeb.DiffComponents.default_loaded(diff_files))
|> assign(:all_labels, Labels.list_labels(repo))
|> assign_comment_form()
|> assign_review_form()
|> assign(:comment_body, "")
|> assign(:page_title, "##{pr.number} #{pr.title}")}
end
defp maybe_probe(pr) do
if pr.state == "open" do
PullRequests.check_mergeability(pr)
else
{:ok, pr.mergeable}
end
end
defp load_diff(repo, pr) do
case Repositories.get_diff(repo, pr.base_sha, pr.head_sha) do
{:ok, files} -> files
_ -> []
end
end
defp assign_comment_form(socket),
do: assign(socket, :comment_form, to_form(PrComment.changeset(%PrComment{}, %{})))
defp assign_review_form(socket),
do:
assign(
socket,
:review_form,
to_form(PrReview.changeset(%PrReview{}, %{state: "approved"}))
)
@impl true
def handle_event("expand_diff_file", params, socket),
do: {:noreply, GitGudWeb.DiffComponents.expand(socket, params)}
def handle_event(
"report",
%{"target_type" => type, "target_id" => id, "reason" => reason},
socket
) do
case socket.assigns.current_scope && socket.assigns.current_scope.user do
nil ->
{:noreply, put_flash(socket, :error, "Sign in to report.")}
user ->
case GitGud.Reports.open(user, {type, String.to_integer(id)}, reason) do
{:ok, _} ->
{:noreply, put_flash(socket, :info, "Report submitted. Thanks.")}
{:error, :already_moderated} ->
{:noreply,
put_flash(socket, :info, "This content has already been moderated.")}
{:error, %Ecto.Changeset{}} ->
{:noreply, put_flash(socket, :info, "Already reported.")}
end
end
end
def handle_event("toggle_state", _params, socket) do
new_state = if socket.assigns.pr.state == "open", do: "closed", else: "open"
{:ok, _} = PullRequests.set_state(socket.assigns.pr, new_state)
{:noreply, reload(socket)}
end
def handle_event("toggle_label", %{"id" => label_id}, socket) do
label = Labels.get_label!(label_id)
pr = socket.assigns.pr
attached? = Enum.any?(pr.labels, &(&1.id == label.id))
if attached?,
do: PullRequests.detach_label(pr, label),
else: PullRequests.attach_label(pr, label)
{:noreply, reload(socket)}
end
def handle_event("update_comment_body", %{"pr_comment" => %{"body" => body}}, socket) do
{:noreply, assign(socket, :comment_body, body)}
end
def handle_event("quote_reply", %{"id" => id}, socket) do
case Enum.find(socket.assigns.pr.comments, &(&1.id == String.to_integer(id))) do
nil ->
{:noreply, socket}
comment ->
quoted = GitGud.Markdown.quote_block(comment.body, comment_author(comment))
next = append_with_blank(socket.assigns.comment_body, quoted)
{:noreply, assign(socket, :comment_body, next)}
end
end
def handle_event("add_comment", %{"pr_comment" => attrs}, socket) do
user = socket.assigns.current_scope.user
case PullRequests.add_comment(socket.assigns.pr, user, attrs) do
{:ok, _} ->
{:noreply, socket |> assign_comment_form() |> assign(:comment_body, "") |> reload()}
{:error, cs} ->
{:noreply, assign(socket, :comment_form, to_form(cs))}
end
end
def handle_event("add_review", %{"pr_review" => attrs}, socket) do
user = socket.assigns.current_scope.user
case PullRequests.add_review(socket.assigns.pr, user, attrs) do
{:ok, _} -> {:noreply, socket |> assign_review_form() |> reload()}
{:error, cs} -> {:noreply, assign(socket, :review_form, to_form(cs))}
end
end
def handle_event("merge", _params, socket) do
user = socket.assigns.current_scope.user
case PullRequests.merge!(socket.assigns.pr, user) do
{:ok, _pr} ->
{:noreply, socket |> put_flash(:info, "Merged.") |> reload()}
{:error, :conflict} ->
{:noreply, put_flash(socket, :error, "Merge conflict — cannot merge.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Merge failed: #{inspect(reason)}")}
end
end
defp reload(socket) do
pr = PullRequests.get_pull_request!(socket.assigns.repo, socket.assigns.pr.number)
assign(socket, :pr, pr)
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="space-y-6">
<GitGudWeb.RepoLive.Header.header
repo={@repo}
handle={@handle}
current_scope={@current_scope}
has_packages?={@has_packages?}
can_admin?={@can_admin?}
latest_run={@latest_run}
/>
<header>
<p class="text-xs opacity-60">
<.link navigate={~p"/r/#{@handle}/#{@repo.name}/pulls"} class="link link-hover">
Back to pull requests
</.link>
</p>
<div class="flex items-baseline justify-between gap-2">
<h1 class="text-2xl font-semibold">
#{@pr.number} — {@pr.title}
</h1>
<button
:if={@current_scope && @current_scope.user && not GitGud.PullRequests.PullRequest.moderated?(@pr)}
type="button"
phx-click="report"
phx-value-target_type="pull_request"
phx-value-target_id={@pr.id}
phx-value-reason="abuse"
class="btn btn-xs btn-ghost opacity-60 hover:opacity-100"
data-confirm="Report this pull request for abuse?"
title="Report"
>
<.icon name="hero-flag" class="size-3" />
</button>
</div>
<p class="text-xs opacity-60 font-mono mt-1">
<span class={["badge", state_class(@pr.state)]}>{@pr.state}</span>
<span :if={@cross_repo?}>
{@source_handle}/{@source_repo.name}:
</span>{@pr.source_ref} → {@pr.target_ref}
<span :if={@cross_repo?} class="badge badge-xs badge-info ml-1">fork</span>
<span :if={@federated?} class="badge badge-xs badge-accent ml-1">federated</span>
<span :if={@pr.merge_commit_sha} class="ml-2">
merged as {Git.short(@pr.merge_commit_sha)}
</span>
</p>
<p :if={@federated? && @source_actor} class="text-xs opacity-60 mt-1">
Offered by
<.link navigate={~p"/actors/#{@source_actor.id}"} class="link link-hover">
{@source_actor.preferred_username || @source_actor.name}@{actor_host(@source_actor)}
</.link>
<span :if={@pr.source_clone_url} class="ml-2 font-mono">
from {@pr.source_clone_url}:{@pr.source_ref}
</span>
</p>
<.commit_status_badges repo={@repo} sha={@pr.head_sha} class="mt-2" />
</header>
<section class="border border-base-300 rounded p-4">
<GitGudWeb.ModerationComponents.moderated_body
target={@pr}
viewer={@current_scope && @current_scope.user}
repo={@repo}
theme={@editor_theme}
/>
</section>
<section class="flex gap-4 text-sm">
<span><strong>{@stats.files_changed}</strong> files</span>
<span class="text-success">+{@stats.insertions}</span>
<span class="text-error">-{@stats.deletions}</span>
</section>
<section>
<h2 class="font-semibold mb-2">Files changed</h2>
<GitGudWeb.DiffComponents.diff
files={@diff_files}
theme={@editor_theme}
loaded_idx={@loaded_diff_idx}
/>
</section>
<section :if={@pr.state == "open"} class="card bg-base-200 p-4 space-y-2">
<p class="text-sm">
<%= cond do %>
<% @pr.mergeable == true -> %>
<.icon name="hero-check-circle" class="size-4 text-success" />
This branch has no conflicts with the target.
<% @pr.mergeable == false -> %>
<.icon name="hero-exclamation-triangle" class="size-4 text-error" />
This branch has conflicts that must be resolved.
<% true -> %>
<.icon name="hero-clock" class="size-4 opacity-60" /> Mergeability not yet checked.
<% end %>
</p>
<div class="flex gap-2">
<button
type="button"
phx-click="merge"
class="btn btn-sm btn-primary"
disabled={@pr.mergeable != true}
>
Merge
</button>
<button type="button" phx-click="toggle_state" class="btn btn-sm btn-ghost">
Close
</button>
</div>
</section>
<section>
<h2 class="font-semibold mb-2">Labels</h2>
<div class="flex flex-wrap gap-1">
<button
:for={l <- @all_labels}
type="button"
phx-click="toggle_label"
phx-value-id={l.id}
class={[
"badge",
Enum.any?(@pr.labels, &(&1.id == l.id)) || "opacity-40"
]}
style={"background-color: " <> l.color <> "; color: white"}
>
{l.name}
</button>
</div>
</section>
<section>
<h2 class="font-semibold mb-2">Reviews</h2>
<ul class="space-y-2">
<li :for={r <- @pr.reviews} class="border border-base-300 rounded p-3 text-sm">
<p class="opacity-60 text-xs mb-1">
{reviewer_name(r.reviewer)} · {r.state} · {format_dt(r.inserted_at)}
</p>
<p :if={r.body}>{r.body}</p>
</li>
</ul>
<.form for={@review_form} id="review-form" phx-submit="add_review" class="mt-3 space-y-2">
<.input
field={@review_form[:state]}
type="select"
label="Review"
options={[
{"Approve", "approved"},
{"Request changes", "changes_requested"},
{"Comment", "commented"}
]}
/>
<.input field={@review_form[:body]} type="textarea" rows="3" label="Body (optional)" />
<button type="submit" class="btn btn-xs btn-primary">Submit review</button>
</.form>
</section>
<section>
<h2 class="font-semibold mb-2">Comments</h2>
<ul class="space-y-3">
<li :for={c <- @pr.comments} class="border border-base-300 rounded p-3">
<div class="flex items-baseline justify-between">
<p class="text-xs opacity-60 mb-1 flex items-center gap-1.5">
<.avatar name={comment_author(c)} size="xs" alt={comment_author(c)} />
{comment_author(c)} · {format_dt(c.inserted_at)}
<span :if={c.source_actor_id} class="badge badge-xs badge-accent ml-1">
federated
</span>
</p>
<div class="flex gap-1">
<button
:if={@current_scope && @current_scope.user}
type="button"
phx-click="quote_reply"
phx-value-id={c.id}
class="btn btn-xs btn-ghost opacity-60 hover:opacity-100"
title="Quote in reply"
>
<.icon name="hero-chat-bubble-left" class="size-3" /> Quote
</button>
<button
:if={@current_scope && @current_scope.user && not GitGud.PullRequests.PrComment.moderated?(c)}
type="button"
phx-click="report"
phx-value-target_type="pr_comment"
phx-value-target_id={c.id}
phx-value-reason="abuse"
class="btn btn-xs btn-ghost opacity-60 hover:opacity-100"
data-confirm="Report this comment for abuse?"
title="Report"
>
<.icon name="hero-flag" class="size-3" />
</button>
</div>
</div>
<GitGudWeb.ModerationComponents.moderated_body
target={c}
viewer={@current_scope && @current_scope.user}
repo={@repo}
theme={@editor_theme}
empty_text="(empty comment)"
/>
</li>
</ul>
<.form
for={@comment_form}
id="pr-comment-form"
phx-change="update_comment_body"
phx-submit="add_comment"
class="mt-4 space-y-2"
>
<label class="label">Add a comment</label>
<textarea
id="pr_comment_body"
name="pr_comment[body]"
rows="6"
class="textarea textarea-bordered w-full font-mono text-sm"
phx-debounce="200"
>{@comment_body}</textarea>
<button type="submit" class="btn btn-sm btn-primary">Comment</button>
</.form>
</section>
</div>
</Layouts.app>
"""
end
defp state_class("open"), do: "badge-success"
defp state_class("merged"), do: "badge-primary"
defp state_class("closed"), do: "badge-neutral"
defp state_class(_), do: ""
defp reviewer_name(%{email: e}), do: e |> String.split("@") |> hd()
defp reviewer_name(_), do: "anonymous"
defp actor_host(%{actor_url: url}) when is_binary(url) do
URI.parse(url).host || "remote"
end
defp actor_host(_), do: "remote"
defp comment_author(%{source_actor: %{} = a}) do
name = a.preferred_username || a.name || "remote"
name <> "@" <> actor_host(a)
end
defp comment_author(%{author: %{} = author}), do: reviewer_name(author)
defp comment_author(_), do: "anonymous"
defp append_with_blank("", new), do: new
defp append_with_blank(existing, new), do: existing <> "\n\n" <> new
defp format_dt(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
end
15.6 KiB · text
5af55d9