14.2 KiB · text History 6280797
defmodule GitGudWeb.PrLive.Reviews do
@moduledoc """
The review screen: who has been asked to review, what verdict each of
them currently holds, and the form for leaving or changing your own.
Split off the conversation page for the same reason the diff was —
these are controls you use while reviewing, not while reading the
discussion, and they were competing for the same column.
"""
use GitGudWeb, :live_view
alias GitGud.Events
alias GitGud.PullRequests
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))
{:ok,
socket
|> assign(:repo, repo)
|> assign(:handle, Storage.repo_handle(repo))
|> GitGudWeb.RepoLive.Header.assign_chrome(repo)
|> assign(:pr, pr)
|> assign_review_form()
|> assign_suggestions()
|> assign(:page_title, "Reviews · ##{pr.number} #{pr.title}")}
end
defp reload(socket) do
pr = PullRequests.get_pull_request!(socket.assigns.repo, socket.assigns.pr.number)
socket |> assign(:pr, pr) |> assign_review_form() |> assign_suggestions()
end
# Offering the author or someone already asked would be noise, so
# both are excluded from the suggestions.
defp assign_suggestions(socket) do
%{repo: repo, pr: pr} = socket.assigns
already = Enum.map(pr.review_requests, & &1.reviewer_id)
exclude = Enum.reject([pr.author_id | already], &is_nil/1)
assign(socket, :suggestions, PullRequests.suggested_reviewers(repo, exclude))
end
defp viewer(socket), do: socket.assigns.current_scope && socket.assigns.current_scope.user
defp assign_review_form(socket) do
mine = my_review(socket.assigns)
default = if self_approval_blocked?(socket.assigns), do: "commented", else: "approved"
assign(
socket,
:review_form,
to_form(PrReview.changeset(%PrReview{}, %{state: (mine && mine.state) || default}))
)
end
defp my_review(%{current_scope: %{user: %{} = user}, pr: pr}),
do: PullRequests.review_by(pr, user)
defp my_review(_assigns), do: nil
defp can_review?(%{current_scope: %{user: %{}}}), do: true
defp can_review?(_assigns), do: false
# The repo can withhold approval from the PR's own author. Commenting
# and requesting changes on your own PR stay available.
defp self_approval_blocked?(%{current_scope: %{user: %{id: uid}}} = assigns),
do: assigns.repo.block_self_approval and assigns.pr.author_id == uid
defp self_approval_blocked?(_assigns), do: false
defp review_options(assigns) do
approve = [{"Approve", "approved"}]
rest = [{"Request changes", "changes_requested"}, {"Comment", "commented"}]
if self_approval_blocked?(assigns), do: rest, else: approve ++ rest
end
# Asking for a review is the author's or a maintainer's call — the
# same people who can close it.
defp can_request_review?(%{current_scope: %{user: %{id: uid}}} = assigns),
do: assigns.can_admin? or assigns.pr.author_id == uid
defp can_request_review?(_assigns), do: false
defp requested_reviewers(pr), do: pr.review_requests
defp awaiting_review?(pr, request),
do: not Enum.any?(pr.reviews, &(&1.reviewer_id == request.reviewer_id))
defp conversation_count(pr), do: Enum.count(pr.comments, &is_nil(&1.file_path))
defp review_badge("approved"), do: "badge-success"
defp review_badge("changes_requested"), do: "badge-error"
defp review_badge(_), do: "badge-ghost"
defp review_word("approved"), do: "approved"
defp review_word("changes_requested"), do: "requested changes"
defp review_word(_), do: "commented"
defp reviewer_name(%{handle: h}) when is_binary(h), do: h
defp reviewer_name(%{email: e}) when is_binary(e), do: e |> String.split("@") |> hd()
defp reviewer_name(_), do: "anonymous"
# Only local users with a handle have a profile page; a federated
# reviewer has neither, so their name stays plain text.
defp profile_handle(%{handle: h}) when is_binary(h), do: h
defp profile_handle(_), do: nil
@impl true
def handle_event("request_review", %{"handle" => handle}, socket) do
pr = socket.assigns.pr
cond do
not can_request_review?(socket.assigns) ->
{:noreply, put_flash(socket, :error, "Only the author or a repo admin can do that.")}
String.trim(handle) == "" ->
{:noreply, socket}
true ->
case GitGud.Accounts.get_user_by_handle(String.trim(handle)) do
nil ->
{:noreply, put_flash(socket, :error, "No such user.")}
reviewer ->
case PullRequests.request_review(pr, reviewer, viewer(socket)) do
{:ok, _request} ->
:ok =
Events.record(pr, "review_requested", viewer(socket), %{
reviewer: reviewer.handle
})
{:noreply, reload(socket)}
{:error, :self_request} ->
{:noreply, put_flash(socket, :error, "You can't request a review from yourself.")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Could not request that review.")}
end
end
end
end
def handle_event("remove_review_request", %{"id" => id}, socket) do
pr = socket.assigns.pr
with true <- can_request_review?(socket.assigns),
request when not is_nil(request) <-
Enum.find(pr.review_requests, &(&1.id == String.to_integer(id))) do
:ok = PullRequests.remove_review_request(pr, request.reviewer)
:ok =
Events.record(pr, "review_request_removed", viewer(socket), %{
reviewer: request.reviewer.handle
})
{:noreply, reload(socket)}
else
_ -> {:noreply, socket}
end
end
def handle_event("add_review", %{"pr_review" => attrs}, socket) do
case viewer(socket) do
nil ->
{:noreply, put_flash(socket, :error, "Sign in to review.")}
user ->
previous = my_review(socket.assigns)
pr = socket.assigns.pr
case PullRequests.add_review(pr, user, attrs) do
{:ok, review} ->
# An unchanged re-submission hands back the row you already
# had; there's no transition to record.
if previous && previous.id == review.id do
{:noreply, socket}
else
:ok =
Events.record(pr, "reviewed", user, %{
from: previous && previous.state,
to: review.state
})
{:noreply, reload(socket)}
end
{:error, :self_approval} ->
{:noreply, put_flash(socket, :error, "You can't approve your own pull request here.")}
{:error, cs} ->
{:noreply, assign(socket, :review_form, to_form(cs))}
end
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="space-y-4">
<GitGudWeb.RepoLive.Header.header
repo={@repo}
handle={@handle}
current_scope={@current_scope}
has_packages?={@has_packages?}
can_admin?={@can_admin?}
latest_run={@latest_run}
open_pulls={@open_pulls}
/>
<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>
<h1 class="text-2xl font-semibold">#{@pr.number}{@pr.title}</h1>
</header>
<GitGudWeb.PrNav.nav
handle={@handle}
repo={@repo}
pr={@pr}
active={:reviews}
comments_count={conversation_count(@pr)}
reviews_count={length(PullRequests.latest_reviews(@pr))}
events_count={Events.count_for(@pr)}
/>
<section>
<h2 class="font-semibold mb-2">Reviewers</h2>
<ul :if={requested_reviewers(@pr) != []} class="space-y-1 mb-2">
<li :for={req <- requested_reviewers(@pr)} class="flex items-center gap-2 text-sm">
<.avatar
name={reviewer_name(req.reviewer)}
size="xs"
alt={reviewer_name(req.reviewer)}
/>
<.link
:if={profile_handle(req.reviewer)}
navigate={~p"/u/#{profile_handle(req.reviewer)}"}
class="link link-hover"
>
{reviewer_name(req.reviewer)}
</.link>
<span :if={is_nil(profile_handle(req.reviewer))}>
{reviewer_name(req.reviewer)}
</span>
<span :if={awaiting_review?(@pr, req)} class="badge badge-xs badge-ghost">
awaiting review
</span>
<button
:if={can_request_review?(assigns)}
type="button"
phx-click="remove_review_request"
phx-value-id={req.id}
class="btn btn-xs btn-ghost opacity-70 hover:opacity-100 ml-auto"
aria-label={"Remove #{reviewer_name(req.reviewer)} as a reviewer"}
>
<.icon name="hero-x-mark" class="size-3" /> Remove
</button>
</li>
</ul>
<p :if={requested_reviewers(@pr) == []} class="text-sm opacity-60 mb-2">
Nobody has been asked to review yet.
</p>
<div :if={can_request_review?(assigns) and @suggestions != []} class="mb-2">
<p class="text-xs opacity-60 mb-1">Reviews often in this repo</p>
<div class="flex flex-wrap gap-1">
<span
:for={{user, count} <- @suggestions}
class="badge badge-outline gap-1 pr-0.5"
title={"#{count} review(s) in this repo"}
>
<.link navigate={~p"/u/#{user.handle}"} class="link link-hover">
{user.handle}
</.link>
<span class="opacity-60">{count}</span>
<button
type="button"
phx-click="request_review"
phx-value-handle={user.handle}
class="btn btn-ghost btn-xs px-1 min-h-0 h-4"
aria-label={"Request a review from #{user.handle}"}
>
<.icon name="hero-plus" class="size-3" />
</button>
</span>
</div>
</div>
<form
:if={can_request_review?(assigns)}
phx-submit="request_review"
class="flex items-end gap-2"
>
<input
type="text"
name="handle"
placeholder="handle"
aria-label="Request a review from"
class="input input-bordered input-xs font-mono flex-1 max-w-xs"
/>
<button type="submit" class="btn btn-xs">Request review</button>
</form>
</section>
<section>
<h2 class="font-semibold mb-2">Reviews</h2>
<p :if={@pr.reviews == []} class="text-sm opacity-60">No reviews yet.</p>
<%!-- The standing verdict per reviewer. Reviews stay
append-only, so this is the latest each of them left. --%>
<ul :if={@pr.reviews != []} class="space-y-2">
<li
:for={r <- PullRequests.latest_reviews(@pr)}
class="border border-base-300 rounded p-3 text-sm"
>
<p class="opacity-60 text-xs mb-1 flex items-center gap-1.5">
<span class={["badge badge-xs", review_badge(r.state)]}>{review_word(r.state)}</span>
<.link
:if={profile_handle(r.reviewer)}
navigate={~p"/u/#{profile_handle(r.reviewer)}"}
class="link link-hover"
>
{reviewer_name(r.reviewer)}
</.link>
<span :if={is_nil(profile_handle(r.reviewer))}>{reviewer_name(r.reviewer)}</span>
</p>
<p :if={r.body}>{r.body}</p>
</li>
</ul>
<details
:if={length(@pr.reviews) > length(PullRequests.latest_reviews(@pr))}
class="mt-2 text-xs"
>
<summary class="cursor-pointer opacity-60 hover:opacity-100 select-none">
Show all {length(@pr.reviews)} reviews
</summary>
<ul class="mt-2 space-y-1">
<li :for={r <- Enum.reverse(@pr.reviews)} class="opacity-70">
<span class={["badge badge-xs", review_badge(r.state)]}>{review_word(r.state)}</span>
<.link
:if={profile_handle(r.reviewer)}
navigate={~p"/u/#{profile_handle(r.reviewer)}"}
class="link link-hover"
>
{reviewer_name(r.reviewer)}
</.link>
<span :if={is_nil(profile_handle(r.reviewer))}>{reviewer_name(r.reviewer)}</span>
</li>
</ul>
</details>
<.form
:if={can_review?(assigns)}
for={@review_form}
id="review-form"
phx-submit="add_review"
class="mt-3 space-y-2 max-w-lg"
>
<p :if={my_review(assigns)} class="text-xs opacity-70">
You {review_word(my_review(assigns).state)}. Submitting again changes your review.
</p>
<p :if={self_approval_blocked?(assigns)} class="text-xs opacity-70">
This repo doesn't let authors approve their own pull requests.
</p>
<.input
field={@review_form[:state]}
type="select"
label="Review"
options={review_options(assigns)}
/>
<.input field={@review_form[:body]} type="textarea" rows="3" label="Body (optional)" />
<button type="submit" class="btn btn-xs btn-primary">
{if my_review(assigns), do: "Update review", else: "Submit review"}
</button>
</.form>
</section>
</div>
</Layouts.app>
"""
end
end