defmodule OpenAgentsWeb.CodeRepoLive do
@moduledoc """
The public repo home (#136): README render, the latest commits, and the
ref list for a forge repo at `/:owner/:repo`. Gated by the repo's
disclosure level (TRANSPARENCY-001 — the page needs :files since it
renders the README). Same public read-only posture as the other
projections; data comes from `OpenAgents.Forge.Browse`'s bounded plumbing.
"""
use OpenAgentsWeb, :live_view
alias OpenAgents.Forge
alias OpenAgents.Forge.Browse
alias OpenAgents.Forge.Pushes
alias OpenAgents.PullRequests
alias OpenAgents.Repositories
alias OpenAgentsWeb.LiveRefresh
alias OpenAgentsWeb.OG
alias OpenAgentsWeb.RepositoryAccess
@impl true
def mount(%{"owner" => owner, "repo" => name}, _session, socket) do
repository = RepositoryAccess.get_visible!(owner, name, socket.assigns.current_user)
base = RepositoryAccess.base(repository)
source? = RepositoryAccess.full_source?(repository, socket.assigns.current_user)
unless Forge.enabled?() and (source? or repository.lifecycle_state != "ready") do
raise OpenAgentsWeb.PublicNotFoundError
end
delete_allowed? =
Repositories.membership_role(repository, socket.assigns.current_user) == "owner"
# A repository that is still provisioning is the one state this page cannot
# render usefully, and it is also the one state that ends on its own. The
# provisioner and the importer announce each transition, so the page hears
# them instead of telling the reader to keep pressing refresh.
#
# A ready repository used to subscribe to nothing at all, which made the
# common case the static one: a push landed and the commit list, the ref
# counts, and the README stayed as they were, and an issue opened anywhere
# left the tab count behind. It hears both now.
if connected?(socket) do
if repository.lifecycle_state != "ready" do
:ok = Repositories.subscribe_provisioning(repository.id)
end
:ok = Pushes.subscribe()
:ok = Repositories.subscribe_issues(repository.id)
end
{:ok,
socket
|> LiveRefresh.init()
|> assign(:page_title, "#{repository.name} · code")
|> assign(:repository, repository)
|> assign(:repository_import, repository.repository_import)
|> assign(:mirror?, Repositories.mirror?(repository))
|> assign(:repo, repository.name)
|> assign(:owner, repository.namespace.slug)
|> assign(:base, base)
|> assign(:og, OG.meta(OG.repo_card_for(repository)))
|> assign(:clone_url, RepositoryAccess.clone_url(repository))
|> assign(:delete_allowed?, delete_allowed?)
|> assign(
:pull_request_settings_form,
pull_request_settings_form(repository.pull_requests_enabled)
)
|> assign(:delete_error, nil)
|> assign(
:delete_form,
to_form(%{"confirmation" => ""}, as: :repository_delete)
)
|> refresh_panel(:source)
|> refresh_panel(:counts)}
rescue
Ecto.NoResultsError -> raise OpenAgentsWeb.PublicNotFoundError
end
@impl true
def handle_info({:repository_provisioning, repository_id}, socket) do
case Repositories.get_visible_repository(repository_id, socket.assigns.current_user) do
nil ->
{:noreply, socket}
%{lifecycle_state: "ready"} ->
# Ready means there is now a head, a README, commits, and refs to read.
# Remounting the route loads them the one way this page knows how.
{:noreply, push_navigate(socket, to: socket.assigns.base)}
repository ->
{:noreply,
socket
|> assign(:repository, repository)
|> assign(:repository_import, repository.repository_import)}
end
end
# One accepted push, matched by storage key. The message describes the push;
# this page describes the repository, so it re-reads rather than rendering
# what it was handed.
def handle_info({:forge_push, %{repo: storage_key}}, socket) do
if storage_key == socket.assigns.repository.storage_key,
do: {:noreply, LiveRefresh.mark_stale(socket, :source, &refresh_panel/2)},
else: {:noreply, socket}
end
def handle_info({:issues_changed, repository_id}, socket) do
if repository_id == socket.assigns.repository.id,
do: {:noreply, LiveRefresh.mark_stale(socket, :counts, &refresh_panel/2)},
else: {:noreply, socket}
end
def handle_info(:live_refresh, socket),
do: {:noreply, LiveRefresh.run(socket, &refresh_panel/2)}
# The Git read and the database read are separate panels, so an issue opened
# in another tab moves the tab count without walking the object store, and a
# push does not re-count the issues.
defp refresh_panel(socket, :source) do
overview = Browse.overview(socket.assigns.repository, 20)
socket
|> assign(:head, overview.head)
|> assign(:readme, overview.readme)
|> assign(:commits, overview.commits)
|> assign(:latest, List.first(overview.commits))
|> assign(:refs, overview.refs)
|> assign(:entries, overview.entries)
|> assign(:branch_count, Enum.count(overview.refs, &(&1.kind == :branch)))
|> assign(:tag_count, Enum.count(overview.refs, &(&1.kind == :tag)))
end
defp refresh_panel(socket, :counts) do
socket
|> assign(:open_issue_count, open_issue_count(socket.assigns.repository))
|> assign(:open_pull_request_count, open_pull_request_count(socket.assigns.repository))
end
@impl true
def handle_event(
"delete_repository",
%{"repository_delete" => %{"confirmation" => confirmation} = params},
socket
) do
expected = "#{socket.assigns.owner}/#{socket.assigns.repo}"
if confirmation == expected do
case Repositories.delete_owned_repository(
socket.assigns.owner,
socket.assigns.repo,
socket.assigns.current_user,
surface: "web"
) do
{:ok, _repository} ->
{:noreply,
socket
|> put_flash(:info, "Repository deleted.")
|> push_navigate(to: ~p"/repositories")}
{:error, :repository_busy} ->
{:noreply,
assign(
socket,
:delete_error,
"Repository provisioning is still running. Try again after it finishes."
)}
{:error, _reason} ->
{:noreply,
assign(socket, :delete_error, "OpenAgents could not delete this repository.")}
end
else
{:noreply,
socket
|> assign(:delete_form, to_form(params, as: :repository_delete))
|> assign(:delete_error, "Type #{expected} exactly to confirm deletion.")}
end
end
def handle_event(
"update_pull_request_setting",
%{"repository" => %{"pull_requests_enabled" => enabled}},
socket
) do
enabled = enabled == "true"
case Repositories.update_pull_request_setting(
socket.assigns.owner,
socket.assigns.repo,
socket.assigns.current_user,
enabled
) do
{:ok, repository} ->
{:noreply,
socket
|> assign(:repository, repository)
|> assign(:pull_request_settings_form, pull_request_settings_form(enabled))
|> put_flash(:info, "Pull request settings updated.")}
{:error, _reason} ->
{:noreply,
put_flash(socket, :error, "OpenAgents could not update pull request settings.")}
end
end
# "none" is the recorded absence of a license, not a missing value, and it
# is rendered as a sentence rather than as the bare token the database holds.
defp upstream_license_label(%{upstream_license: "none"}), do: "No license found upstream"
defp upstream_license_label(%{upstream_license: license}), do: license
defp short(sha), do: String.slice(sha, 0, 12)
# The tab carries a count only when there is something to count, the way the
# component's own `count` attribute is defined: nil renders no badge.
#
# Issues and pull requests are counted apart because they are apart. Both tabs
# read the same number while a pull request was an uncounted-out issue row,
# which is the ambiguity #120 removed.
defp open_issue_count(repository) do
case OpenAgents.Issues.count_issues(repository, state: "open") do
0 -> nil
count -> count
end
rescue
# The tab is decoration over another context's data. A repository whose
# issues cannot be read is still a repository whose code should render.
_error -> nil
end
defp open_pull_request_count(repository) do
case PullRequests.count_open(repository) do
0 -> nil
count -> count
end
rescue
_error -> nil
end
defp initial(name) when is_binary(name) do
name |> String.trim() |> String.first() |> Kernel.||("?") |> String.upcase()
end
defp initial(_name), do: "?"
defp pull_request_settings_form(enabled) do
to_form(%{"pull_requests_enabled" => enabled}, as: :repository)
end
@impl true
def render(assigns) do
~H"""
<Layouts.app
flash={@flash}
sidebar_sections={assigns[:sidebar_sections]}
current_scope={@current_scope}
title="Code"
full_width
>
<main id="code-repo-page" 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={@base} current>Code</:tab>
<:tab
icon="octicon-issue-opened"
navigate={"#{@base}/issues"}
count={@open_issue_count}
>
Issues
</:tab>
<:tab
icon="pull-request-open"
navigate={"#{@base}/pulls"}
count={@open_pull_request_count}
>
Pull requests
</:tab>
<:tab icon="cube" navigate={"#{@base}/projects"}>Projects</:tab>
</.repo_tabs>
</:tabs>
<.alert
:if={@repository.lifecycle_state == "provisioning"}
id="repo-provisioning"
variant={:info}
title="Repository provisioning is in progress"
>
This page updates itself when the repository becomes ready for Git operations.
</.alert>
<.alert
:if={@repository.lifecycle_state == "failed"}
id="repo-provisioning-failed"
variant={:danger}
title="Repository provisioning failed"
>
Error code: <code>{@repository.provision_error_code || "provisioning_failed"}</code>
</.alert>
<.empty
:if={@repository.lifecycle_state == "ready" and is_nil(@head)}
id="repo-empty"
title="This repository is empty"
>
Push the first commit to <code>{@repository.default_branch}</code>
with the clone URL below.
</.empty>
<%!-- Only while the repository has nothing in it. Once there is a
tree, the ref bar carries a Clone control and a second copy of the
same command underneath it is noise -- but an empty repository has no
ref bar, and telling someone how to push the first commit is the only
thing this page can usefully do for them. --%>
<.card :if={@repository.lifecycle_state == "ready" and is_nil(@head)} id="repo-clone">
<h2>Clone</h2>
<code class="block break-all">git clone {@clone_url}</code>
</.card>
<%!-- Counts rather than a list: the ref bar states how many branches
and tags there are, and each name is reachable from there. `commits`
is left unset because the total is not a bounded query -- an invented
number beside two real ones is worse than one missing number. --%>
<.file_table
:if={@head}
owner={@owner}
repo={@repo}
ref={@repository.default_branch}
entries={@entries}
branches={@branch_count}
tags={@tag_count}
>
<:actions>
<.copy_button
id="repo-clone-copy"
text={"git clone #{@clone_url}"}
label="Clone"
copied_label="Copied"
/>
</:actions>
<:commit :if={@latest}>
<.avatar size={:sm} fallback={initial(@latest.author)} label={@latest.author} />
<strong>{@latest.author}</strong>
<span>{@latest.subject}</span>
<.text_button navigate={"#{@base}/commit/#{short(@latest.sha)}"}>
<code>{short(@latest.sha)}</code>
</.text_button>
<.time_ago at={@latest.committed_at} />
</:commit>
</.file_table>
<%!-- A README is an authored document, not a message: its source is
wrapped at an editing width, so hard breaks would end every one of
those wraps in a line break and render the file at the width of its
source rather than the width of this column. --%>
<.card :if={@readme} id="repo-readme">
<h2>{@readme.name}</h2>
<div class="markdown">
{OpenAgents.Markdown.to_html(@readme.blob.content, hardbreaks: false)}
</div>
</.card>
<%!-- The ref bar counts branches and tags; this is where their names
and heads actually are. Without it the counts are the only trace of a
branch that is not the default one. --%>
<.card :if={@head} id="repo-refs">
<h2>Refs</h2>
<ul class="code-refs">
<li :for={ref <- @refs}>
<.badge variant={:dim}>{ref.kind}</.badge>
{ref.name}
<.text_button navigate={"#{@base}/commit/#{short(ref.sha)}"}>
<code>{short(ref.sha)}</code>
</.text_button>
</li>
</ul>
</.card>
<.card :if={@head} id="repo-commits">
<h2>Recent commits</h2>
<ol class="code-commits">
<li :for={commit <- @commits}>
<.text_button navigate={"#{@base}/commit/#{short(commit.sha)}"}>
<code>{short(commit.sha)}</code>
</.text_button>
<span class="code-commit-line">{commit.subject}</span>
<span class="code-commit-author">{commit.author}</span>
<.time_ago at={commit.committed_at} />
</li>
</ol>
</.card>
<.card :if={@delete_allowed?} id="repository-pull-request-settings">
<header>
<h2>Pull requests</h2>
</header>
<p class="text-sm text-muted-foreground">
Allow contributors to propose changes from another hosted repository and branch.
</p>
<.form
for={@pull_request_settings_form}
id="repository-pull-request-settings-form"
phx-submit="update_pull_request_setting"
class="space-y-3"
>
<.input
field={@pull_request_settings_form[:pull_requests_enabled]}
type="checkbox"
label="Allow new pull requests"
/>
<.button id="repository-pull-request-settings-submit" type="submit">
Save pull request settings
</.button>
</.form>
</.card>
<.card
:if={@delete_allowed?}
id="repository-danger-zone"
variant={:danger}
class="space-y-4"
>
<header>
<h2>Delete repository</h2>
</header>
<p class="text-sm text-muted-foreground">
This permanently deletes the repository, its Git history, issues, projects, and
import records. You cannot undo this action.
</p>
<.alert
:if={@delete_error}
id="repository-delete-error"
variant={:danger}
title="Repository was not deleted"
>
{@delete_error}
</.alert>
<.form
for={@delete_form}
id="repository-delete-form"
phx-submit="delete_repository"
class="space-y-3"
>
<.input
field={@delete_form[:confirmation]}
type="text"
label={"Type #{@owner}/#{@repo} to confirm"}
autocomplete="off"
required
/>
<.button
id="repository-delete-submit"
type="submit"
variant={:destructive}
phx-disable-with="Deleting repository…"
>
Delete repository
</.button>
</.form>
</.card>
<:about>
<.repo_about
description={@repository.description}
license={if @mirror?, do: upstream_license_label(@repository)}
>
<%!-- The file that is actually there, under the name it actually
has. A fixed `README.md` is a guess, and a repository whose readme
is named anything else gets a rail link to a 404. --%>
<:link
:if={@readme}
icon="book"
navigate={"#{@base}/blob/#{@repository.default_branch}/#{@readme.name}"}
>
{@readme.name}
</:link>
<:link icon="empty-circle" navigate={"#{@base}/issues"}>Issues</:link>
<:link icon="cube" navigate={"#{@base}/projects"}>Projects</:link>
<:stat icon="branch">
{@branch_count} {if @branch_count == 1, do: "branch", else: "branches"}
</:stat>
<:stat :if={@tag_count > 0} icon="tag">
{@tag_count} {if @tag_count == 1, do: "tag", else: "tags"}
</:stat>
</.repo_about>
<%!-- REPOSITORY-001: an import freezes one authorized ref map and
schedules no later synchronization. Keep that provenance beside
the repository metadata instead of interrupting the code tree. --%>
<%!-- REPOSITORY-001: a mirror is not an owned repository, and the
page says so where a reader is looking at the repository rather
than only in the API. The upstream is named, its license travels
or its absence is stated, and the one-way rule is written down
beside the clone URL that would otherwise imply a push. --%>
<section
:if={@mirror?}
id="repo-upstream-mirror"
class="repo-import-provenance"
aria-labelledby="repo-upstream-mirror-title"
>
<h2 id="repo-upstream-mirror-title">Upstream mirror</h2>
<dl>
<div>
<dt>Upstream</dt>
<dd>
<.link href={@repository.upstream_url} rel="noopener nofollow">
{@repository.upstream_url}
</.link>
</dd>
</div>
<div>
<dt>Upstream license</dt>
<dd>{upstream_license_label(@repository)}</dd>
</div>
<div :if={@repository_import && @repository_import.source_head_sha}>
<dt>Snapshot</dt>
<dd><code>{short(@repository_import.source_head_sha)}</code></dd>
</div>
<div :if={@repository_import}>
<dt>State</dt>
<dd>{@repository_import.state}</dd>
</div>
</dl>
<p>
OpenAgents does not own this repository. It is a one-way copy of the
upstream above, taken once, and it accepts no pushes. Contribute
to the upstream instead.
</p>
</section>
<section
:if={@repository_import && not @mirror?}
id="repo-import-provenance"
class="repo-import-provenance"
aria-labelledby="repo-import-provenance-title"
>
<h2 id="repo-import-provenance-title">Imported from GitHub</h2>
<dl>
<div>
<dt>Source</dt>
<dd><code>{@repository_import.source_full_name}</code></dd>
</div>
<div :if={@repository_import.source_head_sha}>
<dt>Snapshot</dt>
<dd><code>{short(@repository_import.source_head_sha)}</code></dd>
</div>
<div>
<dt>State</dt>
<dd>{@repository_import.state}</dd>
</div>
<div :if={@repository_import.completed_at}>
<dt>Completed</dt>
<dd>
<time datetime={DateTime.to_iso8601(@repository_import.completed_at)}>
{Calendar.strftime(@repository_import.completed_at, "%Y-%m-%d %H:%M UTC")}
</time>
</dd>
</div>
<div :if={@repository_import.error_code}>
<dt>Error code</dt>
<dd><code>{@repository_import.error_code}</code></dd>
</div>
</dl>
<p>
OpenAgents copied this snapshot once. Later GitHub commits do not appear here.
</p>
</section>
</:about>
</.repo_view>
</main>
</Layouts.app>
"""
end
end