lib/openagents_web/live/code_commit_live.ex

436d37facc96 · 10 KB

defmodule OpenAgentsWeb.CodeCommitLive do
  @moduledoc """
  The public commit view (#137): message, provenance trailers, changed
  files, the diff — and the commit's **deploy story**, the receipt chain
  joined from `OpenAgents.Forge.receipts_for/2`: pushed (role, WAL seq),
  promoted, built (modules), and deployed (result, canary, measured
  push→live). A sha with no receipts says so honestly.

  Disclosure per TRANSPARENCY-001: the page needs :ledger; the diff body
  additionally needs :diffs. Live over the forge PubSub, so a promote made
  while the page is open completes its story in place. Public posture as
  the other projections: read-only, sessionless, cannot invoke OpenAgents.
  """

  use OpenAgentsWeb, :live_view

  alias OpenAgents.Forge
  alias OpenAgents.Forge.Browse
  alias OpenAgents.Forge.CommitReferences
  alias OpenAgentsWeb.OG
  alias OpenAgentsWeb.RepositoryAccess

  @impl true
  def mount(%{"owner" => owner, "repo" => name, "sha" => sha}, _session, socket) do
    repository = RepositoryAccess.get_visible!(owner, name, socket.assigns.current_user)
    base = RepositoryAccess.base(repository)

    unless RepositoryAccess.ledger?(repository, socket.assigns.current_user) and Forge.enabled?() do
      raise OpenAgentsWeb.PublicNotFoundError
    end

    commit =
      case Browse.commit(repository, sha) do
        {:ok, commit} -> commit
        _ -> raise OpenAgentsWeb.PublicNotFoundError
      end

    files =
      case Browse.changed_files(repository, commit.sha) do
        {:ok, files} -> files
        _ -> []
      end

    {diff, diff_truncated} =
      if RepositoryAccess.full_source?(repository, socket.assigns.current_user) do
        case Browse.diff(repository, commit.sha) do
          {:ok, diff, truncated} -> {diff, truncated}
          _ -> {nil, false}
        end
      else
        {nil, false}
      end

    diff_files = OpenAgents.Diff.parse(diff)

    if connected?(socket) do
      Enum.each(
        ["forge:target", "forge:deploys"],
        &Phoenix.PubSub.subscribe(OpenAgents.PubSub, &1)
      )
    end

    {:ok,
     socket
     |> assign(:page_title, "#{short(commit.sha)} · #{repository.name}")
     |> assign(:repository, repository)
     |> assign(:repo, repository.storage_key)
     |> assign(:name, repository.name)
     |> assign(:owner, repository.namespace.slug)
     |> assign(:base, base)
     |> assign(:commit, commit)
     |> assign(:closing_references, CommitReferences.closing(commit.message))
     |> assign(:files, files)
     |> assign(
       :og,
       OG.meta(
         OG.commit(repository.namespace.slug, repository.name, commit, files && length(files))
       )
     )
     |> assign(:diff, diff)
     |> assign(:diff_truncated, diff_truncated)
     |> assign(:diff_files, diff_files)
     |> assign(:diff_totals, OpenAgents.Diff.totals(diff_files))
     |> assign(:receipts, Forge.receipts_for(repository.storage_key, commit.sha))}
  rescue
    Ecto.NoResultsError -> raise OpenAgentsWeb.PublicNotFoundError
  end

  @impl true
  def handle_info({event, %{sha: sha}}, socket)
      when event in [:forge_target, :forge_target_status, :forge_deploy] do
    if Forge.sha_match?(sha, socket.assigns.commit.sha) do
      {:noreply,
       assign(
         socket,
         :receipts,
         Forge.receipts_for(socket.assigns.repo, socket.assigns.commit.sha)
       )}
    else
      {:noreply, socket}
    end
  end

  def handle_info(_message, socket), do: {:noreply, socket}

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

  # Only the role prefix of a push principal is ever published.
  defp role_of(principal), do: principal |> to_string() |> String.split(":", parts: 2) |> hd()

  defp ms_text(nil), do: "—"
  defp ms_text(ms) when ms < 1_000, do: "#{ms} ms"
  defp ms_text(ms), do: "#{Float.round(ms / 1_000, 1)} s"

  defp module_text(1), do: "1 module"
  defp module_text(count), do: "#{count} modules"

  defp result_variant("live"), do: :success
  defp result_variant("reverted"), do: :danger
  defp result_variant("failed"), do: :danger
  defp result_variant(_), do: :warning

  defp status_variant("live"), do: :success
  defp status_variant(status) when status in ["failed", "reverted"], do: :danger
  defp status_variant("needs_rolling_replace"), do: :warning
  defp status_variant(_), do: :info

  defp trailer_link?({"Claude-Session", value}), do: String.starts_with?(value, "https://")
  defp trailer_link?(_), do: false

  defp file_status("A"), do: "added"
  defp file_status("M"), do: "modified"
  defp file_status("D"), do: "deleted"
  defp file_status("R" <> _), do: "renamed"
  defp file_status(other), do: other

  defp no_receipts?(receipts) do
    receipts.pushes == [] and receipts.targets == [] and receipts.deploys == [] and
      receipts.builds == []
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      title="Commit"
      full_width
    >
      <main id="code-commit-page" class="app-shell code-shell">
        <section class="code" aria-label="Commit view">
          <header class="code-heading">
            <div>
              <h1 class="code-commit-subject">{@commit.subject}</h1>
              <p class="code-meta">
                <code>{short(@commit.sha)}</code>
                · {@commit.author} · <.time_ago at={@commit.committed_at} />
                <span :for={parent <- @commit.parents}>
                  · parent
                  <.text_button navigate={"#{@base}/commit/#{short(parent)}"}>
                    <code>{short(parent)}</code>
                  </.text_button>
                </span>
              </p>
            </div>
          </header>

          <.card id="commit-message">
            <pre class="code-message">{@commit.message}</pre>
            <dl :if={@commit.trailers != []} class="code-trailers">
              <div :for={{key, value} <- @commit.trailers}>
                <dt>{key}</dt>
                <dd :if={trailer_link?({key, value})}>
                  <.text_button href={value}>{value}</.text_button>
                </dd>
                <dd :if={!trailer_link?({key, value})}>{value}</dd>
              </div>
            </dl>
            <dl :if={@closing_references != []} id="commit-closing-references" class="code-trailers">
              <div :for={reference <- @closing_references}>
                <dt>{String.capitalize(reference.verb)}</dt>
                <dd :if={CommitReferences.same_repository?(reference)}>
                  <.text_button navigate={"#{@base}/issues/#{reference.number}"}>
                    #{reference.number}
                  </.text_button>
                </dd>
                <dd :if={!CommitReferences.same_repository?(reference)}>
                  {reference.owner}/{reference.repository}#{reference.number}
                  <span class="text-muted-foreground">
                    (another repository — recorded, not closed)
                  </span>
                </dd>
              </div>
            </dl>
          </.card>

          <.card id="commit-deploy-story">
            <h2>Deploy story</h2>
            <p class="code-intro">
              What this commit did to the running system — joined from the
              forge receipt chain, the part a commit page elsewhere cannot
              show.
            </p>

            <.empty
              :if={no_receipts?(@receipts)}
              id="commit-no-receipts"
              title="Not deployed through the forge lane"
            >
              No push, promotion, build, or deploy receipt references this
              commit (receipts are scanned over a bounded recent window).
              Changes shipped by full node replacement carry their proof in
              the release gate receipt instead.
            </.empty>

            <dl :if={!no_receipts?(@receipts)} class="code-receipts">
              <div :for={push <- @receipts.pushes}>
                <dt>pushed</dt>
                <dd>
                  by {role_of(push.principal)} · WAL seq {push.wal_seq} · {push.inserted_at}
                </dd>
              </div>
              <div :for={target <- @receipts.targets}>
                <dt>promoted</dt>
                <dd>
                  by {role_of(target.promoted_by)} ·
                  <.badge variant={status_variant(target.status)}>{target.status}</.badge>
                  · {target.inserted_at}
                </dd>
              </div>
              <div :for={build <- @receipts.builds}>
                <dt>built</dt>
                <dd>
                  {length(build.modules || [])} modules in {ms_text(build.duration_ms)}
                </dd>
              </div>
              <div :for={deploy <- @receipts.deploys}>
                <dt>deployed</dt>
                <dd>
                  <.badge variant={result_variant(deploy.result)}>{deploy.result}</.badge>
                  · {module_text(length(deploy.modules || []))} on {length(deploy.nodes || [])} nodes
                  · push→live {ms_text(deploy.push_to_live_ms)}
                </dd>
              </div>
            </dl>
          </.card>

          <.card id="commit-files">
            <h2>Changed files</h2>
            <.empty :if={@files == []} id="commit-no-files" title="No file changes">
              An empty or merge commit.
            </.empty>
            <ul :if={@files != []} class="code-files">
              <li :for={file <- @files}>
                <.badge variant={:dim}>{file_status(file.status)}</.badge>
                <code>{file.path}</code>
              </li>
            </ul>
          </.card>

          <.card :if={@diff} id="commit-diff">
            <h2>Diff</h2>
            <.alert :if={@diff_truncated} id="commit-diff-truncated" variant={:warning}>
              The diff is larger than the display bound; the tail is cut.
            </.alert>

            <p :if={@diff_files != []} id="commit-diff-totals" class="diff-totals">
              {@diff_totals.files} {ngettext("file", "files", @diff_totals.files)} changed,
              <span data-kind="insert">+{@diff_totals.insertions}</span>
              <span data-kind="delete">-{@diff_totals.deletions}</span>
            </p>

            <.diff_file :for={file <- @diff_files} file={file} />

            <%!-- The parser returns [] for input it cannot read. Showing the
            raw text then is better than showing nothing: the diff is a report
            about a repository, and a shape this code has not seen should still
            reach the reader. --%>
            <pre :if={@diff_files == []} class="code-source code-diff"><code>{@diff}</code></pre>
          </.card>

          <footer class="code-footer">
            <p>
              This page updates live while a promote is in flight ·
              <.text_button navigate="/changelog">changelog</.text_button>
            </p>
          </footer>
        </section>
      </main>
    </Layouts.app>
    """
  end
end