lib/openagents_web/live/pull_request_show_live.ex

main at 436d37facc96 · 20 KB

defmodule OpenAgentsWeb.PullRequestShowLive do
  @moduledoc """
  Shows one repository pull request.

  A stacked pull request presents its own layer as the primary review diff
  — the stored boundary OID to the observed head OID — and offers an
  explicit cumulative preview from the current trunk tip. When a parent
  rewrite makes the stored boundary unreachable, the page explains the
  stale boundary and offers a restack action instead of silently rendering
  lower layers inside the layer diff.
  """
  use OpenAgentsWeb, :live_view

  alias OpenAgents.Diff
  alias OpenAgents.Forge.Browse
  alias OpenAgents.Issues
  alias OpenAgents.PullRequests
  alias OpenAgents.Repositories
  alias OpenAgents.Stacks
  alias OpenAgents.Stacks.Restack
  alias OpenAgentsWeb.LiveRefresh
  alias OpenAgentsWeb.OG
  alias OpenAgentsWeb.RepositoryAccess
  alias OpenAgentsWeb.UI.Circle

  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do
    repository = visible_repository!(owner, repo, socket.assigns.current_user)
    pull_request = PullRequests.get_by_number!(repository, String.to_integer(number))

    stack_context = review_context(repository, pull_request)
    stack_history = if stack_context, do: nil, else: merged_history(pull_request)
    stack_placement = stack_context || stack_history

    # Two publishers, because two things move this page. The tab counts and
    # the pull request's own state follow the repository's issues -- a pull
    # request is an issue row with a `pull_requests` record pointing at it --
    # and the stack rebuilds from the outbox events a restack emits.
    if connected?(socket) do
      :ok = Repositories.subscribe_issues(repository.id)
      :ok = Stacks.EventDispatcher.subscribe(repository.id)
    end

    {:ok,
     socket
     |> LiveRefresh.init()
     |> assign(:current_scope, socket.assigns[:current_scope])
     |> assign(:owner, owner)
     |> assign(:repo, repo)
     |> assign(:repository, repository)
     |> assign(:open_issue_count, open_issue_count(repository))
     |> assign(:open_pull_request_count, open_pull_request_count(repository))
     |> assign(:pull_request, pull_request)
     |> assign(:stack_context, stack_context)
     |> assign(:stack_history, stack_history)
     |> assign(:stack_operation, nil)
     |> assign(
       :og,
       OG.meta(
         OG.pull_request(repository.namespace.slug, repository.name, pull_request,
           stack_position: stack_placement && stack_placement.position,
           stack_size: stack_placement && stack_placement.size
         )
       )
     )
     |> assign(
       :can_write,
       Repositories.writable?(repository, socket.assigns.current_user)
     )
     |> assign(
       :diff_readable,
       RepositoryAccess.full_source?(repository, socket.assigns.current_user)
     )}
  end

  def handle_params(params, _uri, socket) do
    view = if params["view"] == "cumulative", do: :cumulative, else: :layer
    {:noreply, socket |> assign(:stack_view, view) |> assign_stack_diff()}
  end

  def handle_info({:issues_changed, repository_id}, socket) do
    if repository_id == socket.assigns.repository.id,
      do: {:noreply, LiveRefresh.mark_stale(socket, :pull_request, &refresh_panel/2)},
      else: {:noreply, socket}
  end

  # The dispatcher delivers at least once and carries a payload for consumers
  # that deduplicate by event id. This page needs neither: it re-reads the
  # stack, so a redelivered event costs one read and says the same thing.
  def handle_info({:stack_event, _event}, socket),
    do: {:noreply, LiveRefresh.mark_stale(socket, :pull_request, &refresh_panel/2)}

  def handle_info(:live_refresh, socket),
    do: {:noreply, LiveRefresh.run(socket, &refresh_panel/2)}

  # Everything the mount derived from the pull request, re-derived. The counts
  # stay aggregates, and authority is re-read rather than remembered, so a
  # viewer who loses write access loses the restack action with it.
  defp refresh_panel(socket, :pull_request) do
    repository = socket.assigns.repository
    number = socket.assigns.pull_request.issue.number

    case fetch_pull_request(repository, number) do
      nil ->
        socket

      pull_request ->
        stack_context = review_context(repository, pull_request)

        socket
        |> assign(:pull_request, pull_request)
        |> assign(:open_issue_count, open_issue_count(repository))
        |> assign(:open_pull_request_count, open_pull_request_count(repository))
        |> assign(:stack_context, stack_context)
        |> assign(:stack_history, if(stack_context, do: nil, else: merged_history(pull_request)))
        |> assign(:can_write, Repositories.writable?(repository, socket.assigns.current_user))
        |> assign_stack_diff()
    end
  end

  defp fetch_pull_request(repository, number) do
    PullRequests.get_by_number!(repository, number)
  rescue
    Ecto.NoResultsError -> nil
  end

  defp review_context(repository, pull_request) do
    case Stacks.review_context(repository, pull_request) do
      {:ok, context} -> context
      {:error, :not_stacked} -> nil
    end
  end

  defp assign_stack_diff(%{assigns: %{stack_context: nil}} = socket) do
    socket |> assign(:diff_files, []) |> assign(:diff_truncated, false)
  end

  defp assign_stack_diff(%{assigns: assigns} = socket) do
    range =
      case assigns.stack_view do
        :cumulative -> assigns.stack_context.cumulative_range
        :layer -> layer_range_if_intact(assigns.stack_context)
      end

    {diff, truncated} =
      with true <- assigns.diff_readable,
           {base, head} <- range,
           {:ok, diff, truncated} <- Browse.diff_range(assigns.repository, base, head) do
        {diff, truncated}
      else
        _unavailable -> {nil, false}
      end

    socket
    |> assign(:diff_files, Diff.parse(diff))
    |> assign(:diff_truncated, truncated)
  end

  defp layer_range_if_intact(%{boundary_state: :stale}), do: nil
  defp layer_range_if_intact(context), do: context.layer_range

  def handle_event("restack", _params, socket) do
    %{assigns: assigns} = socket

    with true <- assigns.can_write and assigns.stack_context != nil,
         {:ok, {operation, _replay_state}} <-
           Restack.request_from_api(
             assigns.repository,
             assigns.stack_context.stack.number,
             %{},
             assigns.current_user,
             Ecto.UUID.generate()
           ) do
      {:noreply,
       socket
       |> assign(:stack_operation, operation)
       |> put_flash(:info, "Stack rebase started.")}
    else
      {:error, {:operation_in_progress, operation_id}} ->
        {:noreply, socket |> load_operation(operation_id) |> refresh_stack()}

      {:error, _reason} ->
        {:noreply, put_flash(socket, :error, "The stack rebase could not start.")}

      false ->
        {:noreply, socket}
    end
  end

  def handle_event("refresh-operation", _params, socket) do
    case socket.assigns.stack_operation do
      nil -> {:noreply, socket}
      operation -> {:noreply, socket |> load_operation(operation.id) |> refresh_stack()}
    end
  end

  def handle_event("unstack", _params, socket) do
    %{assigns: assigns} = socket

    with true <- assigns.can_write and assigns.stack_context != nil,
         {:ok, _result} <-
           Stacks.unstack_from_api(
             assigns.repository,
             assigns.stack_context.stack.number,
             %{"pull_request" => assigns.pull_request.issue.number},
             assigns.current_user,
             Ecto.UUID.generate()
           ) do
      {:noreply,
       socket
       |> put_flash(:info, "The pull request left the stack.")
       |> refresh_stack()}
    else
      {:error, {:operation_in_progress, operation_id}} ->
        {:noreply, load_operation(socket, operation_id)}

      {:error, :not_stack_top} ->
        {:noreply, put_flash(socket, :error, "Only the top layer can leave the stack.")}

      {:error, _reason} ->
        {:noreply, put_flash(socket, :error, "The pull request could not leave the stack.")}

      false ->
        {:noreply, socket}
    end
  end

  defp load_operation(socket, operation_id) do
    %{assigns: assigns} = socket

    case Restack.get_operation(
           assigns.repository,
           assigns.stack_context.stack.number,
           operation_id
         ) do
      {:ok, operation} -> assign(socket, :stack_operation, operation)
      {:error, _reason} -> socket
    end
  end

  defp refresh_stack(socket) do
    %{assigns: assigns} = socket

    socket
    |> assign(:stack_context, review_context(assigns.repository, assigns.pull_request))
    |> assign_stack_diff()
  end

  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      title={@pull_request.issue.title}
      full_width
    >
      <main id="pull-request-show" class="app-shell code-shell">
        <.repo_view
          owner={@owner}
          repo={@repo}
          visibility={if @repository.visibility == "public", do: :public, else: :private}
        >
          <:tabs>
            <.repo_tabs>
              <:tab icon="code" navigate={~p"/#{@owner}/#{@repo}"}>Code</:tab>
              <:tab
                icon="octicon-issue-opened"
                navigate={~p"/#{@owner}/#{@repo}/issues"}
                count={@open_issue_count}
              >
                Issues
              </:tab>
              <:tab
                icon="pull-request-open"
                navigate={~p"/#{@owner}/#{@repo}/pulls"}
                count={@open_pull_request_count}
                current
              >
                Pull requests
              </:tab>
              <:tab icon="cube" navigate={~p"/#{@owner}/#{@repo}/projects"}>Projects</:tab>
            </.repo_tabs>
          </:tabs>

          <article class="mx-auto w-full max-w-5xl px-4 py-8">
            <div class="flex flex-wrap items-center gap-3">
              <Circle.pull_request_state
                id="pull-request-glyph"
                state={layer_state(@pull_request)}
              />
              <h1 class="text-2xl font-semibold text-foreground">{@pull_request.issue.title}</h1>
              <.badge id="pull-request-state" variant={state_variant(layer_state(@pull_request))}>
                {layer_state(@pull_request)}
              </.badge>
            </div>
            <p class="mt-3 text-sm text-muted-foreground">
              #{@pull_request.issue.number} proposes {@pull_request.head_repository.owner}/{@pull_request.head_repository.name}:{@pull_request.head_ref} into {@owner}/{@repo}:{@pull_request.base_ref}.
            </p>
            <div class="mt-8 rounded-xl border border-border bg-card p-6 whitespace-pre-wrap text-foreground">
              {@pull_request.issue.body || "No description provided."}
            </div>

            <section :if={@stack_context} id="stack-review" class="mt-8">
              <div class="flex flex-wrap items-center gap-3">
                <h2 class="text-lg font-semibold text-foreground">
                  Stack #{@stack_context.stack.number} · layer {@stack_context.position} of {@stack_context.size}
                </h2>
                <.badge variant={
                  if(@stack_context.stack.health == "healthy", do: :success, else: :warning)
                }>
                  {@stack_context.stack.health}
                </.badge>
              </div>

              <.stack_map
                id="stack-map"
                number={@stack_context.stack.number}
                trunk={@stack_context.stack.trunk_ref}
                trunk_navigate={~p"/#{@owner}/#{@repo}/tree/#{@stack_context.stack.trunk_ref}"}
                layers={stack_map_layers(@stack_context, @owner, @repo)}
                class="mt-4 max-w-md"
              >
                <:action :if={@can_write}>
                  <.button id="stack-rebase" variant={:outline} size={:sm} phx-click="restack">
                    Rebase the stack
                  </.button>
                  <.button
                    :if={@stack_context.position == @stack_context.size}
                    id="stack-unstack"
                    variant={:outline}
                    size={:sm}
                    phx-click="unstack"
                  >
                    Remove from stack
                  </.button>
                </:action>
              </.stack_map>

              <p id="stack-readiness" class="mt-3 text-sm text-muted-foreground">
                {readiness(@stack_context)}
              </p>

              <.alert
                :if={@stack_operation}
                id="stack-operation-status"
                variant={operation_variant(@stack_operation.state)}
                appearance={:notice}
                label={operation_label(@stack_operation.state)}
                class="mt-4"
              >
                The rebase runs on the server and moves every branch at once when
                it finishes.
                <:action>
                  <.button
                    id="stack-operation-refresh"
                    variant={:outline}
                    size={:sm}
                    phx-click="refresh-operation"
                  >
                    Check progress
                  </.button>
                </:action>
              </.alert>

              <nav class="mt-4 flex gap-2" aria-label="Stack diff views">
                <.button
                  id="stack-view-layer"
                  patch={~p"/#{@owner}/#{@repo}/pulls/#{@pull_request.issue.number}?view=layer"}
                  variant={if @stack_view == :layer, do: :primary, else: :outline}
                  size={:sm}
                >
                  Layer diff
                </.button>
                <.button
                  id="stack-view-cumulative"
                  patch={~p"/#{@owner}/#{@repo}/pulls/#{@pull_request.issue.number}?view=cumulative"}
                  variant={if @stack_view == :cumulative, do: :primary, else: :outline}
                  size={:sm}
                >
                  Cumulative preview
                </.button>
              </nav>

              <p
                :if={@stack_view == :layer}
                id="stack-layer-range"
                class="mt-3 text-sm text-muted-foreground"
              >
                This layer only: {short(elem(@stack_context.layer_range, 0))} → {short(
                  elem(@stack_context.layer_range, 1)
                )}.
              </p>

              <p
                :if={@stack_view == :cumulative and @stack_context.cumulative_range}
                id="stack-cumulative-range"
                class="mt-3 text-sm text-muted-foreground"
              >
                Everything through position {@stack_context.position}: {short(
                  elem(@stack_context.cumulative_range, 0)
                )} → {short(elem(@stack_context.cumulative_range, 1))}.
              </p>

              <.alert
                :if={@stack_view == :layer and @stack_context.boundary_state == :stale}
                id="stack-stale-boundary"
                variant={:warning}
                appearance={:notice}
                label="This layer is based on an outdated parent commit"
                class="mt-4"
              >
                A lower branch was rewritten, so the stored review boundary no longer
                matches the parent branch. Rebase the stack to restore the intended
                review boundary.
                <:action :if={@can_write}>
                  <.button
                    id="stack-restack-action"
                    variant={:outline}
                    size={:sm}
                    phx-click="restack"
                  >
                    Rebase the stack
                  </.button>
                </:action>
              </.alert>

              <div :if={@diff_files != []} class="mt-6 space-y-4">
                <.diff_file :for={file <- @diff_files} file={file} />
              </div>

              <p :if={@diff_truncated} class="mt-3 text-sm text-muted-foreground">
                The diff is truncated.
              </p>
            </section>

            <section :if={@stack_history} id="stack-history" class="mt-8">
              <div class="flex flex-wrap items-center gap-3">
                <h2 class="text-lg font-semibold text-foreground">
                  Stack #{@stack_history.stack.number} · layer {@stack_history.position} of {@stack_history.size}
                </h2>
                <.badge variant={:done}>merged</.badge>
              </div>

              <.stack_map
                id="stack-history-map"
                number={@stack_history.stack.number}
                trunk={@stack_history.stack.trunk_ref}
                trunk_navigate={~p"/#{@owner}/#{@repo}/tree/#{@stack_history.stack.trunk_ref}"}
                layers={stack_map_layers(@stack_history, @owner, @repo)}
                class="mt-4 max-w-md"
              />

              <p id="stack-history-note" class="mt-3 text-sm text-muted-foreground">
                This pull request merged into {@stack_history.stack.trunk_ref} as part of
                the stack.
              </p>
            </section>
          </article>
        </.repo_view>
      </main>
    </Layouts.app>
    """
  end

  defp merged_history(pull_request) do
    case Stacks.merged_context(pull_request) do
      {:ok, context} -> context
      {:error, :not_stacked} -> nil
    end
  end

  defp state_variant("merged"), do: :done
  defp state_variant("open"), do: :success
  defp state_variant("draft"), do: :dim
  defp state_variant(_closed), do: :danger

  defp short(sha), do: String.slice(sha, 0, 12)

  defp stack_map_layers(context, owner, repo) do
    context.stack.entries
    |> Enum.sort_by(& &1.position, :desc)
    |> Enum.map(fn entry ->
      pull_request = entry.pull_request

      %{
        title: pull_request.issue.title,
        number: pull_request.issue.number,
        branch: pull_request.head_ref,
        state: layer_state(pull_request),
        navigate: ~p"/#{owner}/#{repo}/pulls/#{pull_request.issue.number}",
        current: entry.id == context.entry.id
      }
    end)
  end

  defp readiness(context) do
    states = Enum.map(context.stack.entries, &layer_state(&1.pull_request))
    total = length(states)
    draft = Enum.count(states, &(&1 == "draft"))

    cond do
      context.stack.health != "healthy" ->
        "#{layer_count(total)} · the stack needs a rebase before it can merge"

      draft > 0 ->
        "#{total - draft} of #{layer_count(total)} ready · #{draft} still in draft"

      true ->
        "#{layer_count(total)} ready to merge bottom-first"
    end
  end

  defp layer_count(1), do: "1 layer"
  defp layer_count(count), do: "#{count} layers"

  defp operation_variant(state) when state in ["succeeded"], do: :success
  defp operation_variant(state) when state in ["failed", "cancelled"], do: :danger
  defp operation_variant("waiting_for_conflict_resolution"), do: :warning
  defp operation_variant(_state), do: :info

  defp operation_label("pending"), do: "Stack rebase queued"
  defp operation_label("running"), do: "Stack rebase running"
  defp operation_label("waiting_for_conflict_resolution"), do: "Stack rebase paused on a conflict"
  defp operation_label("waiting_for_checks"), do: "Stack rebase waiting for checks"
  defp operation_label("succeeded"), do: "Stack rebase finished"
  defp operation_label("failed"), do: "Stack rebase failed"
  defp operation_label("cancelled"), do: "Stack rebase cancelled"
  defp operation_label(other), do: other

  defp layer_state(pull_request), do: PullRequests.state(pull_request)

  # The repository nav states both numbers because they are two different
  # numbers, which they were not while pull requests counted as issues (#120).
  defp open_issue_count(repository) do
    case Issues.count_issues(repository, state: "open") do
      0 -> nil
      count -> count
    end
  end

  defp open_pull_request_count(repository) do
    case PullRequests.count_open(repository) do
      0 -> nil
      count -> count
    end
  end

  defp visible_repository!(owner, repo, user) do
    Repositories.get_visible_by_path!(owner, repo, user)
  rescue
    Ecto.NoResultsError ->
      raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
  end
end