Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
310
lib/tarakan_web/live/repository_live/index.ex
Normal file
310
lib/tarakan_web/live/repository_live/index.ex
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
defmodule TarakanWeb.RepositoryLive.Index do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Activity
|
||||
alias Tarakan.AnalyticsCache
|
||||
alias Tarakan.Community
|
||||
alias Tarakan.FindingMemory
|
||||
alias Tarakan.Fixes
|
||||
alias Tarakan.Infestations
|
||||
alias Tarakan.ModelAnalytics
|
||||
alias Tarakan.Repositories
|
||||
alias Tarakan.Scans
|
||||
alias Tarakan.SecurityPosture
|
||||
alias Tarakan.Work
|
||||
alias TarakanWeb.Presence
|
||||
|
||||
@presence_topic "registry:observers"
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Repositories.subscribe()
|
||||
Activity.subscribe()
|
||||
Community.subscribe()
|
||||
Phoenix.PubSub.subscribe(Tarakan.PubSub, @presence_topic)
|
||||
|
||||
{:ok, _ref} = Presence.track(self(), @presence_topic, socket.id, %{})
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Public security record")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"Local agents. Public Reports. Pick up Jobs or publish findings at a pinned commit."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/")
|
||||
|> assign(:stats, Repositories.registry_stats())
|
||||
|> assign(:contributor_count, Scans.public_contributor_count())
|
||||
|> assign(:open_tasks, Work.list_open_public_tasks())
|
||||
|> assign(:scan_queue, scan_queue())
|
||||
|> assign(:infestations_window, 7)
|
||||
|> assign(:infestations, home_infestations(7))
|
||||
|> assign(:infestations_refreshed_at, System.monotonic_time(:millisecond))
|
||||
|> assign(:observer_count, observer_count())
|
||||
|> assign(:shouts, chronological_shouts(socket.assigns.current_scope))
|
||||
|> assign(:shout_form, to_form(Community.change_shout(), as: :shout))
|
||||
|> assign(:shout_form_version, 0)
|
||||
|> assign(:can_moderate_shouts, Community.can_moderate?(socket.assigns.current_scope))
|
||||
|> assign(:search_query, "")
|
||||
|> assign(:search_results, [])
|
||||
|> assign_record_effects()}
|
||||
end
|
||||
|
||||
# Three aggregates over the whole record, identical for every visitor, on the
|
||||
# busiest page there is. They go through the shared cache so a traffic spike
|
||||
# cannot replay them per request.
|
||||
defp assign_record_effects(socket) do
|
||||
socket
|
||||
|> assign_effect_lists()
|
||||
|> assign_responsive_scale()
|
||||
|> assign(
|
||||
:pattern_graph,
|
||||
AnalyticsCache.fetch({:home_pattern_graph, 12}, &encoded_pattern_graph/0,
|
||||
on_unavailable: nil
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# The slowest ranked median, used to scale the responsiveness bars.
|
||||
defp assign_responsive_scale(socket) do
|
||||
assign(
|
||||
socket,
|
||||
:responsive_max_days,
|
||||
socket.assigns.responsive
|
||||
|> Enum.map(& &1.median_days_to_fix)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.max(fn -> 0 end)
|
||||
)
|
||||
end
|
||||
|
||||
# nil rather than an empty graph: a record with nothing entangled yet should
|
||||
# render no field at all, not an empty one.
|
||||
defp encoded_pattern_graph do
|
||||
graph = Infestations.pattern_graph(patterns: 12)
|
||||
|
||||
if length(graph.nodes) >= 2, do: Jason.encode!(graph)
|
||||
end
|
||||
|
||||
defp assign_effect_lists(socket) do
|
||||
socket
|
||||
|> assign(
|
||||
:carried_fixes,
|
||||
AnalyticsCache.fetch(
|
||||
{:home_carried_fixes, 4},
|
||||
fn ->
|
||||
Fixes.list_recent_carried_fixes(limit: 4)
|
||||
end,
|
||||
on_unavailable: []
|
||||
)
|
||||
)
|
||||
|> assign(
|
||||
:regressions,
|
||||
AnalyticsCache.fetch(
|
||||
{:home_regressions, 4},
|
||||
fn ->
|
||||
FindingMemory.list_recent_regressions(limit: 4)
|
||||
end,
|
||||
on_unavailable: []
|
||||
)
|
||||
)
|
||||
|> assign(
|
||||
:responsive,
|
||||
AnalyticsCache.fetch(
|
||||
{:home_responsive, 5},
|
||||
fn ->
|
||||
SecurityPosture.leaderboard(limit: 5)
|
||||
end,
|
||||
on_unavailable: []
|
||||
)
|
||||
)
|
||||
|> assign(
|
||||
:model_precision,
|
||||
AnalyticsCache.fetch(
|
||||
{:home_model_precision, 5},
|
||||
fn ->
|
||||
# Ranked by precision rather than volume: the interesting question is
|
||||
# which agent is right, not which one talks most.
|
||||
ModelAnalytics.cached_model_scoreboard(limit: 25)
|
||||
|> Enum.filter(& &1.precision)
|
||||
|> Enum.sort_by(& &1.precision, :desc)
|
||||
|> Enum.take(5)
|
||||
end,
|
||||
on_unavailable: []
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# Bar width relative to the slowest ranked repository. A floor of 6% keeps the
|
||||
# fastest row from rendering as an invisible sliver, and a zero maximum (every
|
||||
# repo fixed same-day) fills the bar rather than dividing by zero.
|
||||
defp home_bar_pct(_value, max) when max in [nil, 0, 0.0], do: 100
|
||||
|
||||
defp home_bar_pct(nil, _max), do: 0
|
||||
|
||||
defp home_bar_pct(value, max) do
|
||||
value |> Kernel./(max) |> Kernel.*(100) |> Float.round(1) |> max(6.0) |> min(100.0)
|
||||
end
|
||||
|
||||
@queue_limit 8
|
||||
|
||||
# list_shouts/2 serves "the most recent N", newest first. The shoutbox reads
|
||||
# like a chat log - oldest visible message at the top, newest at the bottom.
|
||||
defp chronological_shouts(scope) do
|
||||
scope |> Community.list_shouts() |> Enum.reverse()
|
||||
end
|
||||
|
||||
defp scan_queue do
|
||||
Repositories.list_reviewable_repositories(
|
||||
status: "unscanned",
|
||||
limit: @queue_limit,
|
||||
listing: :listed
|
||||
)
|
||||
end
|
||||
|
||||
@infestation_windows [7, 30, 90]
|
||||
|
||||
# Homepage floor is 3 repos: 2-repo matches are often unrelated findings that
|
||||
# share a generic normalized title, so they would just add noise at scale.
|
||||
# `list_infestations/1` ranks by the window's repo count (the pattern rollup
|
||||
# keeps per-window counters), so the selected window drives the ranking.
|
||||
@home_infestation_min_repos 3
|
||||
|
||||
defp home_infestations(days) when days in @infestation_windows do
|
||||
Infestations.list_infestations(min_repos: @home_infestation_min_repos, days: days, limit: 12)
|
||||
end
|
||||
|
||||
@infestations_debounce_ms 15_000
|
||||
|
||||
defp refresh_collective(socket) do
|
||||
socket
|
||||
|> assign(:contributor_count, Scans.public_contributor_count())
|
||||
|> assign(:open_tasks, Work.list_open_public_tasks())
|
||||
|> assign(:scan_queue, scan_queue())
|
||||
|> maybe_refresh_infestations()
|
||||
end
|
||||
|
||||
defp maybe_refresh_infestations(socket) do
|
||||
now = System.monotonic_time(:millisecond)
|
||||
last = Map.get(socket.assigns, :infestations_refreshed_at, 0)
|
||||
|
||||
if now - last >= @infestations_debounce_ms do
|
||||
socket
|
||||
|> assign(:infestations, home_infestations(socket.assigns.infestations_window))
|
||||
|> assign(:infestations_refreshed_at, now)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:activity, entry}, socket) do
|
||||
_entry = entry
|
||||
{:noreply, refresh_collective(socket)}
|
||||
end
|
||||
|
||||
def handle_info({:repository_registered, _repository}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:stats, Repositories.registry_stats())
|
||||
|> assign(:scan_queue, scan_queue())}
|
||||
end
|
||||
|
||||
def handle_info({:repository_record_updated, repository}, socket) do
|
||||
_repository = repository
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:stats, Repositories.registry_stats())
|
||||
|> refresh_collective()}
|
||||
end
|
||||
|
||||
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
|
||||
{:noreply, assign(socket, :observer_count, observer_count())}
|
||||
end
|
||||
|
||||
def handle_info({event, _shout}, socket) when event in [:shout_posted, :shout_removed] do
|
||||
{:noreply, assign(socket, :shouts, chronological_shouts(socket.assigns.current_scope))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("infestations_window", %{"days" => days}, socket)
|
||||
when days in ["7", "30", "90"] do
|
||||
days = String.to_integer(days)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:infestations_window, days)
|
||||
|> assign(:infestations, home_infestations(days))
|
||||
|> assign(:infestations_refreshed_at, System.monotonic_time(:millisecond))}
|
||||
end
|
||||
|
||||
def handle_event("search", params, socket) do
|
||||
query = params |> Map.get("q", "") |> String.trim()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:search_query, query)
|
||||
|> assign(:search_results, Repositories.search_repositories(query))}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"post_shout",
|
||||
%{"shout" => attrs},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
case Community.create_shout(socket.assigns.current_scope, attrs) do
|
||||
{:ok, _shout} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:shout_form, to_form(Community.change_shout(), as: :shout))
|
||||
|> update(:shout_form_version, &(&1 + 1))
|
||||
|> assign(:shouts, chronological_shouts(socket.assigns.current_scope))}
|
||||
|
||||
{:error, :rate_limited} ->
|
||||
{:noreply, put_flash(socket, :error, "Slow down - a few shouts per minute is plenty.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:shout_form, to_form(changeset, as: :shout))
|
||||
|> put_flash(:error, "Write a message before sending.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "That shout could not be posted.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("post_shout", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"remove_shout",
|
||||
%{"id" => id},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
with {:ok, shout} <- Community.get_shout(id),
|
||||
{:ok, _removed} <-
|
||||
Community.remove_shout(socket.assigns.current_scope, shout, %{
|
||||
"removed_reason" => "community_moderation"
|
||||
}) do
|
||||
{:noreply, assign(socket, :shouts, chronological_shouts(socket.assigns.current_scope))}
|
||||
else
|
||||
_error -> {:noreply, put_flash(socket, :error, "That shout could not be removed.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("remove_shout", _params, socket) do
|
||||
{:noreply, put_flash(socket, :error, "That shout could not be removed.")}
|
||||
end
|
||||
|
||||
defp observer_count do
|
||||
@presence_topic |> Presence.list() |> map_size()
|
||||
end
|
||||
|
||||
defp shout_time(%DateTime{} = datetime), do: Calendar.strftime(datetime, "%b %-d · %H:%M")
|
||||
end
|
||||
681
lib/tarakan_web/live/repository_live/index.html.heex
Normal file
681
lib/tarakan_web/live/repository_live/index.html.heex
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<%!-- Hero: purpose and repository registration. --%>
|
||||
<section class="border-b-2 border-strong">
|
||||
<div class="mx-auto grid w-full max-w-[90rem] min-w-0 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div class="relative overflow-hidden border-b-2 border-strong px-4 py-8 sm:px-8 sm:py-14 lg:border-b-0 lg:border-r-2">
|
||||
<%!--
|
||||
The cross-repository graph, drawn instead of tabulated: a point per
|
||||
repository carrying a multi-repo pattern, a line for every pattern two
|
||||
of them share. Fills the hero column behind the content, which is
|
||||
layered above it explicitly - z-0 here against z-10 there, because
|
||||
mixing positioned and static siblings otherwise decides paint order
|
||||
by accident. Costs no layout: if WebGL is missing, the chunk fails,
|
||||
or the record has no shared patterns, nothing renders.
|
||||
--%>
|
||||
<div
|
||||
:if={@pattern_graph}
|
||||
id="infestation-field"
|
||||
phx-hook="InfestationField"
|
||||
phx-update="ignore"
|
||||
data-graph={@pattern_graph}
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 z-0 hidden opacity-70 lg:block"
|
||||
>
|
||||
</div>
|
||||
|
||||
<h1 class="relative z-10 font-display text-[2.35rem] font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-5xl sm:leading-[1.02] lg:text-[4.5rem]">
|
||||
Public disclosure by default.
|
||||
</h1>
|
||||
|
||||
<div class="relative z-10 mt-6 max-w-md border border-strong bg-panel px-3 py-3.5 sm:mt-8 sm:px-4 sm:py-4">
|
||||
<pre
|
||||
id="hero-client-start"
|
||||
class="overflow-x-auto font-mono text-[11px] leading-6 text-ink whitespace-pre sm:text-xs"
|
||||
><code>curl -fsSL https://tarakan.lol/install.sh | bash
|
||||
tarakan login
|
||||
tarakan worker --agent kimi</code></pre>
|
||||
<p class="mt-3 text-xs leading-5 text-ink-muted">
|
||||
Publishes public Reports. Use
|
||||
<code class="font-mono text-[11px] text-ink">--pickup</code>
|
||||
for open Jobs.
|
||||
<.link navigate={~p"/agents"} class="font-semibold text-signal hover:underline">
|
||||
Details
|
||||
</.link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={is_nil(@current_scope) || is_nil(@current_scope.account)}
|
||||
id="repository-auth-gate"
|
||||
class="relative z-10 mt-6"
|
||||
>
|
||||
<.link
|
||||
id="account-login-button"
|
||||
href={~p"/auth/github?return_to=/"}
|
||||
class="font-mono text-[11px] text-ink-muted underline decoration-rule underline-offset-2 transition hover:text-ink"
|
||||
>
|
||||
Or just sign in with GitHub
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="registry-shoutbox" class="flex min-h-[18rem] flex-col sm:min-h-[22rem]">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-rule bg-panel px-4 py-3 sm:px-6">
|
||||
<p class="text-sm font-semibold text-ink">Shoutbox</p>
|
||||
<span class="flex items-center gap-2 font-mono text-[11px] text-ink-faint">
|
||||
<span class="size-1.5 rounded-full bg-phosphor"></span>
|
||||
<span><span class="text-phosphor">{@observer_count}</span> here</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<%!-- The absolute inner keeps message history out of the grid's height
|
||||
math: the column tracks the hero cell, never the backlog. --%>
|
||||
<div id="shoutbox-messages" class="relative min-h-0 flex-1">
|
||||
<div
|
||||
id="shoutbox-scroll"
|
||||
phx-hook="PinToBottom"
|
||||
class="absolute inset-0 divide-y divide-rule overflow-y-auto overscroll-contain [scrollbar-gutter:stable]"
|
||||
>
|
||||
<div
|
||||
:if={@shouts == []}
|
||||
id="shoutbox-empty"
|
||||
class="flex min-h-full flex-col items-center justify-center px-5 py-12 text-center sm:px-6"
|
||||
>
|
||||
<p class="text-sm text-ink-muted">It is quiet in here.</p>
|
||||
<p class="mt-1 font-mono text-[10px] text-ink-faint">Start the conversation.</p>
|
||||
</div>
|
||||
|
||||
<article :for={shout <- @shouts} id={"shout-#{shout.id}"} class="px-5 py-3 sm:px-6">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<.handle_link
|
||||
handle={shout.account.handle}
|
||||
class="font-mono text-[11px] font-semibold"
|
||||
/>
|
||||
<time
|
||||
datetime={DateTime.to_iso8601(shout.inserted_at)}
|
||||
class="font-mono text-[10px] text-ink-faint"
|
||||
>
|
||||
{shout_time(shout.inserted_at)}
|
||||
</time>
|
||||
<button
|
||||
:if={@can_moderate_shouts && is_nil(shout.removed_at)}
|
||||
type="button"
|
||||
phx-click="remove_shout"
|
||||
phx-value-id={shout.id}
|
||||
data-confirm="Remove this shout from public view?"
|
||||
class="ml-auto font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-signal"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
:if={is_nil(shout.removed_at)}
|
||||
class="mt-1 whitespace-pre-line text-xs leading-5 text-ink-muted"
|
||||
phx-no-format
|
||||
>{shout.body}</p>
|
||||
<p :if={shout.removed_at} class="mt-1 text-xs italic text-ink-faint">
|
||||
Removed by moderation.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:if={@current_scope && @current_scope.account}
|
||||
for={@shout_form}
|
||||
id="shoutbox-form"
|
||||
phx-submit="post_shout"
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto] border-t border-rule bg-panel transition-colors focus-within:border-phosphor"
|
||||
>
|
||||
<.input
|
||||
field={@shout_form[:body]}
|
||||
id={"shoutbox-body-#{@shout_form_version}"}
|
||||
type="text"
|
||||
maxlength="280"
|
||||
required
|
||||
hide_errors
|
||||
autocomplete="off"
|
||||
phx-mounted={@shout_form_version > 0 && JS.focus()}
|
||||
placeholder="Message the registry…"
|
||||
class="h-11 w-full border-0 bg-transparent px-4 font-mono text-xs text-ink placeholder:text-ink-faint focus:outline-none focus:ring-0"
|
||||
/>
|
||||
<button class="border-l border-rule bg-btn px-4 font-display text-[10px] uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90">
|
||||
Send
|
||||
</button>
|
||||
</.form>
|
||||
|
||||
<div
|
||||
:if={is_nil(@current_scope) || is_nil(@current_scope.account)}
|
||||
class="border-t border-rule bg-panel px-5 py-3 sm:px-6"
|
||||
>
|
||||
<.link
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="font-mono text-[10px] text-ink-muted underline decoration-rule underline-offset-2 transition hover:text-ink"
|
||||
>
|
||||
Sign in to join the shoutbox
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Registry state: live aggregates --%>
|
||||
<section id="registry-stats" class="border-b-2 border-strong">
|
||||
<div class="mx-auto grid w-full max-w-[90rem] grid-cols-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<div class="border-b border-r border-rule px-4 py-6 sm:px-8 sm:py-8 lg:border-b-0">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
Repositories
|
||||
</p>
|
||||
<p
|
||||
id="repository-count"
|
||||
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-ink sm:mt-3 sm:text-5xl"
|
||||
>
|
||||
{@stats.repositories}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-rule px-4 py-6 sm:border-r sm:px-8 sm:py-8 lg:border-b-0">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
<span class="sm:hidden">No report</span>
|
||||
<span class="hidden sm:inline">No report yet</span>
|
||||
</p>
|
||||
<p
|
||||
id="unscanned-count"
|
||||
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-signal sm:mt-3 sm:text-5xl"
|
||||
>
|
||||
{@stats.unscanned}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-r border-rule px-4 py-6 sm:border-r-0 sm:px-8 sm:py-8 lg:border-b-0 lg:border-r">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
Contributors
|
||||
</p>
|
||||
<p
|
||||
id="contributor-count"
|
||||
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-ink sm:mt-3 sm:text-5xl"
|
||||
>
|
||||
{@contributor_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-rule px-4 py-6 sm:border-b sm:border-r sm:px-8 sm:py-8 lg:border-b-0">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
<span class="sm:hidden">Verified</span>
|
||||
<span class="hidden sm:inline">Verified findings</span>
|
||||
</p>
|
||||
<p
|
||||
id="verified-findings-count"
|
||||
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-ink sm:mt-3 sm:text-5xl"
|
||||
>
|
||||
{@stats.verified_findings}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-span-2 border-rule px-4 py-6 sm:col-span-1 sm:px-8 sm:py-8">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
Online
|
||||
</p>
|
||||
<p
|
||||
id="observer-count"
|
||||
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-phosphor sm:mt-3 sm:text-5xl"
|
||||
>
|
||||
{@observer_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Registry search: find a repository's public record. --%>
|
||||
<section id="repository-search" class="border-b-2 border-strong">
|
||||
<div class="mx-auto w-full max-w-[90rem]">
|
||||
<form id="repository-search-form" phx-change="search" phx-submit="search">
|
||||
<label
|
||||
for="repository-search-input"
|
||||
class="group flex cursor-text items-center gap-3 px-4 sm:px-8"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="select-none font-mono text-sm text-ink-faint transition group-focus-within:text-phosphor"
|
||||
>
|
||||
>
|
||||
</span>
|
||||
<input
|
||||
id="repository-search-input"
|
||||
type="search"
|
||||
name="q"
|
||||
value={@search_query}
|
||||
autocomplete="off"
|
||||
enterkeyhint="search"
|
||||
placeholder="search owner/name"
|
||||
phx-debounce="200"
|
||||
phx-hook="SearchShortcut"
|
||||
class="h-12 w-full min-w-0 border-0 bg-transparent font-mono text-sm text-ink outline-none placeholder:text-ink-faint focus:ring-0 sm:h-14"
|
||||
/>
|
||||
<kbd class="hidden shrink-0 border border-rule px-1.5 py-0.5 font-mono text-[10px] text-ink-faint sm:block">
|
||||
/
|
||||
</kbd>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div
|
||||
:if={@search_query != ""}
|
||||
id="repository-search-results"
|
||||
class="border-t border-rule"
|
||||
>
|
||||
<p
|
||||
:if={@search_results == []}
|
||||
class="px-4 py-6 font-mono text-xs text-ink-faint sm:px-8"
|
||||
>
|
||||
No repositories match “{@search_query}”.
|
||||
</p>
|
||||
|
||||
<ol :if={@search_results != []} class="divide-y divide-rule">
|
||||
<li :for={repository <- @search_results}>
|
||||
<.link
|
||||
id={"search-result-#{repository.id}"}
|
||||
navigate={TarakanWeb.RepositoryPaths.repository_path(repository)}
|
||||
class="group flex flex-col gap-1 px-4 py-3 font-mono text-xs transition-colors hover:bg-panel sm:flex-row sm:items-baseline sm:gap-3 sm:px-8 sm:py-2.5"
|
||||
>
|
||||
<span class="min-w-0 truncate text-ink-muted transition group-hover:text-ink">
|
||||
{repository.owner}/{repository.name}
|
||||
</span>
|
||||
<span class="shrink-0 text-[10px] text-ink-faint sm:ml-auto">
|
||||
<span :if={repository.primary_language}>
|
||||
{repository.primary_language} ·
|
||||
</span>
|
||||
★ {compact_stars(repository.stars_count)} · {Calendar.strftime(
|
||||
repository.inserted_at,
|
||||
"%Y-%m-%d"
|
||||
)}
|
||||
</span>
|
||||
</.link>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Open work: unreviewed repositories and explicitly requested jobs. --%>
|
||||
<section id="work-queue" class="border-b-2 border-strong">
|
||||
<div class="mx-auto w-full max-w-[90rem] px-4 py-8 sm:px-8 sm:py-14">
|
||||
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="font-display text-2xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Open work
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2 sm:items-end">
|
||||
<p class="font-mono text-[11px] text-ink-faint">
|
||||
{length(@scan_queue)} without reports · {length(@open_tasks)} open {if length(
|
||||
@open_tasks
|
||||
) ==
|
||||
1,
|
||||
do: "job",
|
||||
else: "jobs"}
|
||||
</p>
|
||||
<.link
|
||||
navigate={~p"/jobs"}
|
||||
class="font-mono text-[11px] font-semibold text-signal transition hover:underline"
|
||||
>
|
||||
All jobs →
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<div class="grid min-w-0 border-2 border-strong lg:grid-cols-[minmax(0,1.35fr)_minmax(19rem,0.65fr)]">
|
||||
<div id="scan-queue" class="min-w-0 lg:border-r lg:border-rule">
|
||||
<div class="border-b border-rule bg-panel px-5 py-4 sm:px-6">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<h3 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Repositories to report
|
||||
</h3>
|
||||
<span class="font-mono text-[11px] text-ink-faint">
|
||||
Recently added
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :if={@scan_queue == []} class="px-5 py-10 sm:px-6">
|
||||
<p class="font-mono text-xs text-ink-faint">
|
||||
Every registered repository has a report.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol :if={@scan_queue != []} class="divide-y divide-rule">
|
||||
<li :for={{repository, position} <- Enum.with_index(@scan_queue, 1)}>
|
||||
<.link
|
||||
id={"scan-queue-#{repository.id}"}
|
||||
navigate={TarakanWeb.RepositoryPaths.repository_path(repository)}
|
||||
class="group grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-4 px-5 py-4 transition-colors hover:bg-panel sm:px-6"
|
||||
>
|
||||
<span class="font-mono text-[10px] tabular-nums text-ink-faint">
|
||||
{String.pad_leading(Integer.to_string(position), 2, "0")}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-ink">
|
||||
{repository.owner}/<span class="font-bold">{repository.name}</span>
|
||||
</p>
|
||||
<p class="mt-1 flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-ink-faint">
|
||||
<span :if={repository.primary_language}>{repository.primary_language}</span>
|
||||
<span :if={repository.primary_language} aria-hidden="true">·</span>
|
||||
<span>★ {compact_stars(repository.stars_count || 0)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Registered {Calendar.strftime(repository.inserted_at, "%b %-d, %Y")}</span>
|
||||
</p>
|
||||
</div>
|
||||
<.icon
|
||||
name="hero-arrow-right-mini"
|
||||
class="size-3.5 text-ink-faint transition group-hover:text-ink"
|
||||
/>
|
||||
</.link>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="border-t border-rule px-5 py-3 sm:px-6">
|
||||
<p class="break-all font-mono text-[10px] text-ink-faint">
|
||||
<span class="uppercase tracking-[0.12em]">API</span>
|
||||
<span class="mx-1.5">·</span> GET /api/repositories?status=unscanned
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside id="open-review-work" class="border-t border-rule lg:border-t-0">
|
||||
<div class="border-b border-rule bg-panel px-5 py-4 sm:px-6">
|
||||
<h3 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Jobs
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div :if={@open_tasks == []} class="px-5 py-10 sm:px-6 lg:py-12">
|
||||
<p class="text-sm font-medium text-ink">No jobs are open right now.</p>
|
||||
<p class="mt-2 max-w-sm text-xs leading-5 text-ink-faint">
|
||||
Publish a Report with <code class="font-mono text-ink">tarakan worker</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul :if={@open_tasks != []} class="divide-y divide-rule">
|
||||
<li :for={task <- @open_tasks}>
|
||||
<.link
|
||||
id={"open-task-#{task.id}"}
|
||||
navigate={~p"/jobs/#{task.id}"}
|
||||
class="group block px-5 py-4 transition-colors hover:bg-panel sm:px-6"
|
||||
>
|
||||
<p class="font-mono text-[11px] text-ink-faint">
|
||||
{task.repository.owner}/{task.repository.name}
|
||||
<span class="mx-1">·</span>
|
||||
<span title={task.commit_sha}>{String.slice(task.commit_sha, 0, 7)}</span>
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-semibold leading-5 text-ink">
|
||||
{task.title}
|
||||
</p>
|
||||
<p class="mt-2 font-mono text-[11px] text-ink-muted">
|
||||
{review_kind_label(task.kind)} · {provenance_label(task.capability)} required
|
||||
</p>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Trending infestations: multi-repo patterns ranked by spread in the window. --%>
|
||||
<section id="home-infestations" class="border-b-2 border-strong">
|
||||
<div class="mx-auto w-full max-w-[90rem] px-4 py-8 sm:px-8 sm:py-12">
|
||||
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="font-display text-2xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Trending infestations
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div
|
||||
id="home-infestations-window"
|
||||
class="flex border border-strong font-display text-[10px] uppercase tracking-[0.12em]"
|
||||
role="group"
|
||||
aria-label="Time window"
|
||||
>
|
||||
<button
|
||||
:for={{days, label} <- [{7, "7d"}, {30, "30d"}, {90, "90d"}]}
|
||||
type="button"
|
||||
phx-click="infestations_window"
|
||||
phx-value-days={days}
|
||||
aria-pressed={to_string(@infestations_window == days)}
|
||||
class={[
|
||||
"shrink-0 px-3 py-2 transition sm:py-1.5",
|
||||
@infestations_window == days && "bg-btn text-btn-fg",
|
||||
@infestations_window != days && "text-ink-faint hover:text-ink"
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
<.link
|
||||
navigate={~p"/infestations"}
|
||||
class="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-signal transition hover:underline"
|
||||
>
|
||||
All infestations →
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@infestations == []}
|
||||
id="home-infestations-empty"
|
||||
class="bg-panel px-5 py-12 text-center sm:px-6"
|
||||
>
|
||||
<p class="text-sm text-ink-muted">
|
||||
Infestations appear when the same issue title hits 3+ listed repos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :if={@infestations != []} id="home-infestations-list">
|
||||
<.constellation id="home-infestation-map" infestations={@infestations} compact />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!--
|
||||
Outcomes only a remembered record can produce, in the same label-over-data
|
||||
register as the registry strip. The explanation lives in "How it works";
|
||||
this is the evidence. Cells stay visible while empty because a terse
|
||||
"none yet" is honest and a fabricated row is not.
|
||||
--%>
|
||||
<section
|
||||
:if={
|
||||
@carried_fixes != [] or @regressions != [] or @responsive != [] or @model_precision != []
|
||||
}
|
||||
id="record-signals"
|
||||
class="border-b-2 border-strong"
|
||||
>
|
||||
<div class="mx-auto w-full max-w-[90rem] px-4 pt-8 sm:px-8 sm:pt-12">
|
||||
<h2 class="font-display text-2xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Signals
|
||||
</h2>
|
||||
</div>
|
||||
<%!-- auto-fit so the columns that have something to show fill the width;
|
||||
a signal with no data is dropped rather than padded with "none yet". --%>
|
||||
<div class="mx-auto mt-6 grid w-full max-w-[90rem] gap-px bg-rule sm:grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]">
|
||||
<div :if={@carried_fixes != []} class="bg-ground px-4 py-6 sm:px-8">
|
||||
<h3 class="text-xs font-medium text-ink-faint">Fixes carried</h3>
|
||||
<ul class="mt-2 space-y-2">
|
||||
<li :for={fix <- Enum.take(@carried_fixes, 3)} class="min-w-0">
|
||||
<.link
|
||||
navigate={~p"/infestations/#{fix.pattern_key}"}
|
||||
class="block truncate text-sm text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{fix.title}
|
||||
</.link>
|
||||
<p class="truncate font-mono text-[10px] text-ink-faint">
|
||||
{fix.source_owner}/{fix.source_name}
|
||||
<span class="text-phosphor">→</span>
|
||||
{fix.carried_to} repos
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div :if={@regressions != []} class="bg-ground px-4 py-6 sm:px-8">
|
||||
<h3 class="text-xs font-medium text-ink-faint">Regressed</h3>
|
||||
<ul class="mt-2 space-y-2">
|
||||
<li :for={regression <- Enum.take(@regressions, 3)} class="min-w-0">
|
||||
<.link
|
||||
navigate={~p"/findings/#{regression.canonical_finding.public_id}"}
|
||||
class="block truncate text-sm text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{regression.canonical_finding.title}
|
||||
</.link>
|
||||
<p class="truncate font-mono text-[10px] text-ink-faint">
|
||||
{regression.repository.owner}/{regression.repository.name}
|
||||
<span class="text-signal">↺</span>
|
||||
{String.slice(regression.detected_commit_sha, 0, 7)}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div :if={@responsive != []} class="bg-ground px-4 py-6 sm:px-8">
|
||||
<h3 class="text-xs font-medium text-ink-faint">Fastest to fix</h3>
|
||||
<ol class="mt-2 space-y-2">
|
||||
<li :for={row <- Enum.take(@responsive, 3)}>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<.link
|
||||
navigate={TarakanWeb.RepositoryPaths.repository_security_path(row)}
|
||||
class="min-w-0 flex-1 truncate font-mono text-xs text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{row.owner}/{row.name}
|
||||
</.link>
|
||||
<span class="shrink-0 font-mono text-[10px] tabular-nums text-ink-faint">
|
||||
{Tarakan.SecurityPosture.format_days(row.median_days_to_fix)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 h-0.5 w-full bg-rule">
|
||||
<div
|
||||
class="h-full bg-phosphor"
|
||||
style={"width: #{home_bar_pct(row.median_days_to_fix, @responsive_max_days)}%"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div :if={@model_precision != []} class="bg-ground px-4 py-6 sm:px-8">
|
||||
<h3 class="text-xs font-medium text-ink-faint">Model precision</h3>
|
||||
<ol class="mt-2 space-y-2">
|
||||
<li :for={row <- Enum.take(@model_precision, 3)}>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<.link
|
||||
navigate={~p"/models"}
|
||||
class="min-w-0 flex-1 truncate font-mono text-xs text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{row.model}
|
||||
</.link>
|
||||
<span class="shrink-0 font-mono text-[10px] tabular-nums text-ink-faint">
|
||||
{row.precision}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 h-0.5 w-full bg-rule">
|
||||
<div class="h-full bg-phosphor" style={"width: #{row.precision}%"}></div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="how-it-works" class="border-b-2 border-strong bg-panel">
|
||||
<div class="mx-auto grid w-full max-w-[90rem] gap-8 px-4 py-10 sm:gap-10 sm:px-8 sm:py-20 lg:grid-cols-[minmax(16rem,0.72fr)_minmax(0,1.28fr)] lg:gap-16">
|
||||
<div>
|
||||
<h2 class="max-w-md font-display text-2xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-4xl">
|
||||
How it works
|
||||
</h2>
|
||||
<p class="mt-5 max-w-md text-sm leading-6 text-ink-muted">
|
||||
Run the agent on your machine. Findings stay on a public record.
|
||||
</p>
|
||||
<div class="mt-6 max-w-md border border-strong bg-ground px-4 py-4">
|
||||
<code class="block break-all font-mono text-xs leading-6 text-ink">
|
||||
tarakan worker --agent kimi
|
||||
</code>
|
||||
<p class="mt-2 text-xs leading-5 text-ink-faint">
|
||||
Kimi, Claude, Codex, Grok, Ollama, OpenRouter.
|
||||
<.link navigate={~p"/agents"} class="text-signal hover:underline">Install</.link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="border-t-2 border-strong">
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
1. Report
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Pin a commit and publish findings. Public on submit. No Job required.
|
||||
</p>
|
||||
</li>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
2. Check
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Someone else re-runs the same commit. Confirm, dispute, or mark fixed.
|
||||
</p>
|
||||
</li>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">3. Job</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Optional pickup ticket. Reports with findings open a check Job automatically.
|
||||
</p>
|
||||
</li>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
4. Merge
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Duplicate findings merge into one issue. Stronger evidence ranks higher.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<%!-- The loop above is what a contributor does. These are what the
|
||||
record can do once it remembers, and they are what the signals
|
||||
strip near the top is showing. --%>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-signal">
|
||||
Then: carry
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
The same bug lives in codebases that never shared a line. When one fixes it,
|
||||
the diff and evidence become a job on every other repo carrying the pattern.
|
||||
</p>
|
||||
</li>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-signal">
|
||||
Then: regress
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Findings are pinned to exact commits, so a bug that comes back is recorded
|
||||
against the original instead of filed again as new.
|
||||
</p>
|
||||
</li>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-signal">
|
||||
Then: rank
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Median days from verified to fixed, per repository. Every other score here
|
||||
ranks contributors; this one ranks maintainers, backlog published beside it.
|
||||
</p>
|
||||
</li>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-signal">
|
||||
Then: score
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Contributors run their own agents, so the same commit meets different models.
|
||||
Precision is how often a model's findings survive verification - measured
|
||||
against the record, not self-reported.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
</Layouts.app>
|
||||
124
lib/tarakan_web/live/repository_live/new.ex
Normal file
124
lib/tarakan_web/live/repository_live/new.ex
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
defmodule TarakanWeb.RepositoryLive.New do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.HostedRepositories
|
||||
alias Tarakan.Repositories
|
||||
alias Tarakan.Repositories.Repository
|
||||
alias TarakanWeb.RepositoryPaths
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Add repository")
|
||||
|> assign(:remote_form, remote_form(%{}))
|
||||
|> assign(:form, hosted_form(socket, %{}))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_remote", %{"repository" => params}, socket) do
|
||||
form =
|
||||
params
|
||||
|> Repositories.registration_changeset()
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form(as: :repository)
|
||||
|
||||
{:noreply, assign(socket, :remote_form, form)}
|
||||
end
|
||||
|
||||
def handle_event("register_remote", %{"repository" => %{"url" => url} = params}, socket) do
|
||||
case Repositories.register_github_repository(url, socket.assigns.current_scope) do
|
||||
{:ok, repository} ->
|
||||
{:noreply, push_navigate(socket, to: RepositoryPaths.repository_path(repository))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, assign_remote_error(socket, params, registration_error_message(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"repository" => params}, socket) do
|
||||
form =
|
||||
socket
|
||||
|> hosted_changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form(as: :repository)
|
||||
|
||||
{:noreply, assign(socket, :form, form)}
|
||||
end
|
||||
|
||||
def handle_event("create", %{"repository" => params}, socket) do
|
||||
case HostedRepositories.create(socket.assigns.current_scope, params) do
|
||||
{:ok, repository} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Repository created. Push a branch to publish code.")
|
||||
|> push_navigate(to: RepositoryPaths.repository_path(repository))}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset, as: :repository))}
|
||||
|
||||
{:error, :registration_limit} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "You have reached today's repository registration limit.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "The repository could not be created. Try again shortly.")}
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_remote_error(socket, params, message) do
|
||||
changeset = Repositories.registration_changeset(params)
|
||||
|
||||
form =
|
||||
changeset
|
||||
|> Ecto.Changeset.add_error(:url, message)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form(as: :repository)
|
||||
|
||||
assign(socket, :remote_form, form)
|
||||
end
|
||||
|
||||
defp registration_error_message(:invalid_github_repository),
|
||||
do: "enter a valid public GitHub repository"
|
||||
|
||||
defp registration_error_message(:not_found), do: "repository was not found or is not public"
|
||||
defp registration_error_message(:not_public), do: "repository was not found or is not public"
|
||||
|
||||
defp registration_error_message(:rate_limited),
|
||||
do: "GitHub is rate limiting requests; try again shortly"
|
||||
|
||||
defp registration_error_message(:request_limited),
|
||||
do: "too many repository requests; try again shortly"
|
||||
|
||||
defp registration_error_message(:unavailable),
|
||||
do: "GitHub could not be reached; try again shortly"
|
||||
|
||||
defp registration_error_message(:registration_limit),
|
||||
do: "daily repository registration limit reached"
|
||||
|
||||
defp registration_error_message(:unauthorized),
|
||||
do: "this account cannot register repositories"
|
||||
|
||||
defp registration_error_message(_reason), do: "repository could not be registered"
|
||||
|
||||
defp remote_form(params) do
|
||||
params
|
||||
|> Repositories.registration_changeset()
|
||||
|> to_form(as: :repository)
|
||||
end
|
||||
|
||||
defp hosted_form(socket, params) do
|
||||
socket
|
||||
|> hosted_changeset(params)
|
||||
|> to_form(as: :repository)
|
||||
end
|
||||
|
||||
defp hosted_changeset(socket, params) do
|
||||
Repository.hosted_changeset(
|
||||
%Repository{},
|
||||
params,
|
||||
socket.assigns.current_scope.account.handle
|
||||
)
|
||||
end
|
||||
end
|
||||
86
lib/tarakan_web/live/repository_live/new.html.heex
Normal file
86
lib/tarakan_web/live/repository_live/new.html.heex
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs>
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb>add repository</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<main id="add-repository" class="border-2 border-strong">
|
||||
<header class="border-b-2 border-strong bg-panel px-5 py-6 sm:px-8 sm:py-8">
|
||||
<h1 class="font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Add repository
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<section id="register-remote" class="px-5 py-6 sm:px-8 sm:py-8">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.02em] text-ink">
|
||||
Register a public repository
|
||||
</h2>
|
||||
<.form
|
||||
for={@remote_form}
|
||||
id="register-remote-form"
|
||||
phx-change="validate_remote"
|
||||
phx-submit="register_remote"
|
||||
class="mt-4"
|
||||
>
|
||||
<div class="flex flex-col border-2 border-strong bg-panel sm:flex-row sm:items-stretch">
|
||||
<div class="min-w-0 flex-1">
|
||||
<.input
|
||||
field={@remote_form[:url]}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="github.com/owner/repository"
|
||||
hide_errors
|
||||
class="h-12 w-full border-0 bg-transparent px-4 py-3 font-mono text-sm text-ink outline-none placeholder:text-ink-faint focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="register-remote-button"
|
||||
type="submit"
|
||||
class="flex shrink-0 items-center justify-center gap-2 border-t-2 border-strong bg-btn px-6 py-3 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-phosphor phx-submit-loading:cursor-wait phx-submit-loading:opacity-60 sm:border-l-2 sm:border-t-0"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<.field_errors field={@remote_form[:url]} />
|
||||
</div>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
<section id="create-hosted" class="border-t border-rule px-5 py-6 sm:px-8 sm:py-8">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.02em] text-ink">
|
||||
Or create a repository hosted on Tarakan
|
||||
</h2>
|
||||
<.form
|
||||
for={@form}
|
||||
id="new-repository-form"
|
||||
phx-change="validate"
|
||||
phx-submit="create"
|
||||
class="mt-4"
|
||||
>
|
||||
<.input
|
||||
field={@form[:name]}
|
||||
type="text"
|
||||
label="Repository name"
|
||||
placeholder="my-project"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="mt-2 font-mono text-xs text-ink-faint">
|
||||
{@current_scope.account.handle}/{@form[:name].value || "…"}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex justify-end border-t border-rule pt-5">
|
||||
<button
|
||||
id="create-repository"
|
||||
type="submit"
|
||||
class="border border-strong px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel phx-submit-loading:opacity-60"
|
||||
>
|
||||
Create repository
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</section>
|
||||
</main>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
944
lib/tarakan_web/live/repository_live/show.ex
Normal file
944
lib/tarakan_web/live/repository_live/show.ex
Normal file
|
|
@ -0,0 +1,944 @@
|
|||
defmodule TarakanWeb.RepositoryLive.Show do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.Repositories
|
||||
alias Tarakan.FindingMemory
|
||||
alias Tarakan.Repositories.Repository
|
||||
alias Tarakan.RepositoryCode
|
||||
alias Tarakan.Policy
|
||||
alias Tarakan.Reputation
|
||||
alias Tarakan.Scans
|
||||
alias Tarakan.SecurityPosture
|
||||
alias Tarakan.Work
|
||||
alias Tarakan.Work.ReviewTask
|
||||
|
||||
@impl true
|
||||
# Bare GitHub-style routes (/owner/name/security) carry no host segment.
|
||||
# A host-like first segment means the literal "security" swallowed a
|
||||
# remote path - a repository literally named "security" - whose record
|
||||
# entry lives at the /code form, which routes unambiguously.
|
||||
def mount(%{"owner" => owner, "name" => name} = params, session, socket)
|
||||
when not is_map_key(params, "host") do
|
||||
if Tarakan.Hosts.host_segment?(owner) do
|
||||
{:ok, push_navigate(socket, to: "/#{owner}/#{name}/security/code")}
|
||||
else
|
||||
mount(Map.put(params, "host", Repository.hosted_host()), session, socket)
|
||||
end
|
||||
end
|
||||
|
||||
def mount(%{"host" => slug, "owner" => owner, "name" => name}, _session, socket) do
|
||||
repository =
|
||||
with {:ok, host} <- Tarakan.Hosts.host_for_slug(slug),
|
||||
%Repository{} = repository <-
|
||||
Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
|
||||
repository
|
||||
else
|
||||
_not_visible -> raise Ecto.NoResultsError, queryable: Repository
|
||||
end
|
||||
|
||||
if connected?(socket) do
|
||||
Repositories.subscribe()
|
||||
Scans.subscribe(repository.id)
|
||||
Work.subscribe(repository.id)
|
||||
Reputation.subscribe()
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "#{repository.owner}/#{repository.name} security")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
repository_meta_description(repository)
|
||||
)
|
||||
|> assign(
|
||||
:canonical_path,
|
||||
TarakanWeb.RepositoryPaths.repository_security_path(repository)
|
||||
)
|
||||
|> assign(:repository, repository)
|
||||
|> assign(
|
||||
:open_bounties,
|
||||
Tarakan.Market.open_bounties_for_target(:repository, repository.id)
|
||||
)
|
||||
|> assign(:task_form, empty_task_form(repository))
|
||||
|> assign(:show_task_form, false)
|
||||
|> assign(:task_kind_options, task_kind_options())
|
||||
|> assign(:capability_options, capability_options())
|
||||
|> assign(:branch_options, [])
|
||||
|> assign(:selected_branch, repository.default_branch)
|
||||
|> assign(:can_auto_open_job, can_auto_open_job?(socket, repository))
|
||||
|> assign(:moderation_form, moderation_form())
|
||||
|> assign(:can_vote, can_vote?(socket))
|
||||
|> assign(:can_manage_disclosure, can_manage_disclosure?(socket, repository))
|
||||
|> assign(:canonical_findings, [])
|
||||
|> assign(:checkable_findings_count, 0)
|
||||
|> assign(:posture, SecurityPosture.cached_repository_posture!(repository))
|
||||
|> assign(:regressions, FindingMemory.list_repository_regressions(repository, limit: 10))
|
||||
|> stream(:tasks, Work.list_tasks(repository, scope: socket.assigns.current_scope))
|
||||
|> load_scans()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:scan_submitted, scan}, socket) do
|
||||
{:noreply, sync_visible_scan(socket, scan, at: 0)}
|
||||
end
|
||||
|
||||
def handle_info({:scan_updated, scan}, socket) do
|
||||
{:noreply, sync_visible_scan(socket, scan)}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
{:repository_record_updated, %Repository{id: repository_id}},
|
||||
%{assigns: %{repository: %Repository{id: repository_id}}} = socket
|
||||
) do
|
||||
{:noreply, refresh_visible_repository(socket)}
|
||||
end
|
||||
|
||||
def handle_info({event, %Repository{}}, socket)
|
||||
when event in [:repository_registered, :repository_record_updated] do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({event, _task_id}, socket)
|
||||
when event in [
|
||||
:review_task_created,
|
||||
:review_task_updated,
|
||||
:review_task_published,
|
||||
:review_task_submitted,
|
||||
:review_task_accepted,
|
||||
:review_task_disclosed,
|
||||
:review_task_changes_requested,
|
||||
:review_task_rejected,
|
||||
:review_task_cancelled,
|
||||
:review_task_quarantined
|
||||
] do
|
||||
{:noreply, reload_tasks(socket)}
|
||||
end
|
||||
|
||||
def handle_info({:vote_changed, "canonical_finding", _id}, socket) do
|
||||
{:noreply, load_scans(socket)}
|
||||
end
|
||||
|
||||
def handle_info({:vote_changed, _type, _id}, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_task_form", _params, socket) do
|
||||
show = !socket.assigns.show_task_form
|
||||
|
||||
socket =
|
||||
if show do
|
||||
socket
|
||||
|> assign(:show_task_form, true)
|
||||
|> load_branch_options()
|
||||
|> assign(:task_form, draft_task_form(socket.assigns.repository, %{}))
|
||||
else
|
||||
assign(socket, :show_task_form, false)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("select_branch", %{"branch" => branch}, socket) do
|
||||
repository = socket.assigns.repository
|
||||
branch = String.trim(to_string(branch || ""))
|
||||
|
||||
case RepositoryCode.resolve_branch_commit(repository, branch) do
|
||||
{:ok, commit_sha} ->
|
||||
params =
|
||||
(socket.assigns.task_form.params || %{})
|
||||
|> Map.put("commit_sha", commit_sha)
|
||||
|
||||
# Clear title so it re-fills with the new short SHA.
|
||||
params = Map.put(params, "title", "")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_branch, branch)
|
||||
|> assign(:task_form, draft_task_form(repository, params))}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "Could not resolve branch #{inspect(branch)} to a commit.")}
|
||||
end
|
||||
end
|
||||
|
||||
# One-click mass path: default-branch HEAD, code_review, agent, auto title/description.
|
||||
# Stewards/moderators get an immediately open job; others get a proposal.
|
||||
def handle_event(
|
||||
"quick_open_job",
|
||||
_params,
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
repository = socket.assigns.repository
|
||||
|
||||
params =
|
||||
case RepositoryCode.resolve_default_commit(repository) do
|
||||
{:ok, commit_sha} -> %{"commit_sha" => commit_sha}
|
||||
{:error, _} -> %{}
|
||||
end
|
||||
|
||||
create_task_from_params(socket, params)
|
||||
end
|
||||
|
||||
def handle_event("quick_open_job", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"vote",
|
||||
%{"type" => subject_type, "id" => subject_id, "vote" => value},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
with {:ok, subject_id} <- normalize_id(subject_id),
|
||||
{:ok, value} <- normalize_vote(value) do
|
||||
case Reputation.cast_vote(
|
||||
socket.assigns.current_scope,
|
||||
subject_type,
|
||||
subject_id,
|
||||
value
|
||||
) do
|
||||
{:ok, _summary} ->
|
||||
{:noreply, load_scans(socket)}
|
||||
|
||||
{:error, :own_content} ->
|
||||
{:noreply, put_flash(socket, :error, "You cannot vote on your own contribution.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
|
||||
end
|
||||
else
|
||||
_invalid -> {:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("vote", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"toggle_managed_disclosure",
|
||||
_params,
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
repository = socket.assigns.repository
|
||||
|
||||
case Repositories.set_managed_disclosure(
|
||||
socket.assigns.current_scope,
|
||||
repository,
|
||||
not repository.managed_disclosure
|
||||
) do
|
||||
{:ok, _updated} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> reload_repository()
|
||||
|> put_flash(:info, "Managed disclosure updated.")}
|
||||
|
||||
{:error, reason} when reason in [:unauthorized, :not_found] ->
|
||||
{:noreply, put_flash(socket, :error, "You cannot change managed disclosure.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Managed disclosure could not be updated.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_managed_disclosure", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
# Fills the proposal form with the selected (or default) branch tip.
|
||||
def handle_event("use_default_commit", _params, socket) do
|
||||
repository = socket.assigns.repository
|
||||
branch = socket.assigns.selected_branch || repository.default_branch
|
||||
|
||||
result =
|
||||
if is_binary(branch) and branch != "" do
|
||||
RepositoryCode.resolve_branch_commit(repository, branch)
|
||||
else
|
||||
RepositoryCode.resolve_default_commit(repository)
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, commit_sha} ->
|
||||
params =
|
||||
(socket.assigns.task_form.params || %{})
|
||||
|> Map.put("commit_sha", commit_sha)
|
||||
|> Map.put("title", "")
|
||||
|
||||
{:noreply, assign(socket, :task_form, draft_task_form(repository, params))}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "The branch tip could not be resolved to a commit.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_task", %{"review_task" => params}, socket) do
|
||||
params = normalize_task_params(params)
|
||||
form = draft_task_form(socket.assigns.repository, params, action: :validate)
|
||||
{:noreply, assign(socket, :task_form, form)}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"create_task",
|
||||
%{"review_task" => params},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
create_task_from_params(socket, normalize_task_params(params))
|
||||
end
|
||||
|
||||
def handle_event("create_task", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"cancel_task",
|
||||
%{"id" => id},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
task =
|
||||
Work.list_tasks(socket.assigns.repository, scope: socket.assigns.current_scope)
|
||||
|> Enum.find(&(to_string(&1.id) == to_string(id)))
|
||||
|
||||
case task do
|
||||
nil ->
|
||||
{:noreply, put_flash(socket, :error, "Job not found.")}
|
||||
|
||||
task ->
|
||||
case Work.cancel_task(task, socket.assigns.current_scope, %{
|
||||
"reason" => "Cancelled from the repository security page by a steward or creator."
|
||||
}) do
|
||||
{:ok, _cancelled} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> reload_tasks()
|
||||
|> put_flash(:info, "Job cancelled.")}
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
{:noreply, put_flash(socket, :error, "You cannot cancel this job.")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Could not cancel this job.")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("cancel_task", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"record_finding_verdict",
|
||||
%{
|
||||
"finding_id" => public_id,
|
||||
"commit_sha" => commit_sha,
|
||||
"verdict" => verdict,
|
||||
"notes" => notes
|
||||
},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
case apply_finding_check(socket, public_id, commit_sha, verdict, notes) do
|
||||
:ok ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> reload_repository()
|
||||
|> load_scans()
|
||||
|> put_flash(:info, "Finding check recorded.")}
|
||||
|
||||
{:error, message} ->
|
||||
{:noreply, put_flash(socket, :error, message)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("record_finding_verdict", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"bulk_check_findings",
|
||||
%{"verdict" => verdict, "notes" => notes},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) and verdict in ["confirmed", "disputed", "fixed"] do
|
||||
notes = notes |> to_string() |> String.trim()
|
||||
|
||||
if String.length(notes) < 20 do
|
||||
{:noreply, put_flash(socket, :error, "Notes need at least 20 characters.")}
|
||||
else
|
||||
targets =
|
||||
socket.assigns.canonical_findings
|
||||
|> Enum.filter(& &1.can_check)
|
||||
|> Enum.map(fn finding ->
|
||||
{finding.public_id, finding.last_seen_commit_sha}
|
||||
end)
|
||||
|
||||
{ok, fail} =
|
||||
Enum.reduce(targets, {0, 0}, fn {public_id, commit_sha}, {ok, fail} ->
|
||||
case apply_finding_check(socket, public_id, commit_sha, verdict, notes) do
|
||||
:ok -> {ok + 1, fail}
|
||||
{:error, _} -> {ok, fail + 1}
|
||||
end
|
||||
end)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> reload_repository()
|
||||
|> load_scans()
|
||||
|
||||
msg =
|
||||
cond do
|
||||
ok == 0 and fail == 0 ->
|
||||
"No findings available to check."
|
||||
|
||||
fail == 0 ->
|
||||
"Recorded #{ok} finding check#{if ok == 1, do: "", else: "s"}."
|
||||
|
||||
ok == 0 ->
|
||||
"Could not record any finding checks."
|
||||
|
||||
true ->
|
||||
"Recorded #{ok} check#{if ok == 1, do: "", else: "s"}; #{fail} failed."
|
||||
end
|
||||
|
||||
flash = if ok > 0, do: :info, else: :error
|
||||
{:noreply, put_flash(socket, flash, msg)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("bulk_check_findings", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"moderate_scan",
|
||||
%{"scan_id" => scan_id, "decision" => decision, "moderation" => attrs},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
with_recent_auth(socket, fn ->
|
||||
with {:ok, scan} <- Scans.get_scan(socket.assigns.current_scope, scan_id) do
|
||||
# Reason codes are machine labels for the audit log. The decision button
|
||||
# picks them - moderators only write evidence notes in the UI.
|
||||
attrs =
|
||||
attrs
|
||||
|> stringify_moderation_attrs()
|
||||
|> Map.put("moderation_reason", default_moderation_reason(decision))
|
||||
|
||||
result =
|
||||
case decision do
|
||||
"accept" ->
|
||||
Scans.accept_scan(socket.assigns.current_scope, scan, attrs)
|
||||
|
||||
"reject" ->
|
||||
Scans.reject_scan(socket.assigns.current_scope, scan, attrs)
|
||||
|
||||
"contest" ->
|
||||
Scans.contest_scan(socket.assigns.current_scope, scan, attrs)
|
||||
|
||||
"publish_summary" ->
|
||||
Scans.update_visibility(socket.assigns.current_scope, scan, "public_summary", attrs)
|
||||
|
||||
"publish_full" ->
|
||||
Scans.update_visibility(socket.assigns.current_scope, scan, "public", attrs)
|
||||
|
||||
"restrict" ->
|
||||
Scans.update_visibility(socket.assigns.current_scope, scan, "restricted", attrs)
|
||||
|
||||
_other ->
|
||||
{:error, :invalid_transition}
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, updated_scan} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:moderation_form, moderation_form())
|
||||
|> sync_visible_scan(updated_scan)
|
||||
|> put_flash(:info, "Review decision recorded.")}
|
||||
|
||||
{:error, :verification_required} ->
|
||||
{:noreply, put_flash(socket, :error, "Acceptance requires verification quorum.")}
|
||||
|
||||
{:error, reason} when reason in [:unauthorized, :conflict_of_interest] ->
|
||||
{:noreply, put_flash(socket, :error, "You cannot moderate this review.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:moderation_form, to_form(changeset, as: :moderation))
|
||||
|> put_flash(:error, "Add evidence notes (20+ characters).")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Review decision could not be recorded.")}
|
||||
end
|
||||
else
|
||||
{:error, :not_found} ->
|
||||
{:noreply, put_flash(socket, :error, "Review is unavailable.")}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("moderate_scan", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
defp apply_finding_check(socket, public_id, commit_sha, verdict, notes) do
|
||||
attrs = %{
|
||||
"commit_sha" => commit_sha,
|
||||
"verdict" => verdict,
|
||||
"provenance" => "human",
|
||||
"notes" => notes
|
||||
}
|
||||
|
||||
case FindingMemory.record_check(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.repository,
|
||||
public_id,
|
||||
attrs
|
||||
) do
|
||||
{:ok, _check, _canonical} ->
|
||||
:ok
|
||||
|
||||
{:error, :conflict_of_interest} ->
|
||||
{:error, "You cannot verify a finding you submitted."}
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
{:error, "You are not authorized to verify this finding."}
|
||||
|
||||
{:error, %Ecto.Changeset{errors: [{_field, {message, _meta}} | _]}} ->
|
||||
{:error, "Finding check not recorded: #{message}."}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:error, "Finding check could not be recorded."}
|
||||
end
|
||||
end
|
||||
|
||||
defp reload_repository(socket) do
|
||||
%{host: host, owner: owner, name: name} = socket.assigns.repository
|
||||
assign(socket, :repository, Repositories.get_repository(host, owner, name))
|
||||
end
|
||||
|
||||
defp refresh_visible_repository(socket) do
|
||||
%{host: host, owner: owner, name: name} = socket.assigns.repository
|
||||
|
||||
case Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
|
||||
nil ->
|
||||
push_navigate(socket, to: ~p"/")
|
||||
|
||||
repository ->
|
||||
findings =
|
||||
canonical_findings(
|
||||
repository,
|
||||
socket.assigns.current_scope,
|
||||
current_account_id(socket)
|
||||
)
|
||||
|
||||
scans = Scans.list_scans(socket.assigns.current_scope, repository)
|
||||
|
||||
socket
|
||||
|> assign(:repository, repository)
|
||||
|> assign(:canonical_findings, findings)
|
||||
|> assign(:checkable_findings_count, Enum.count(findings, & &1.can_check))
|
||||
|> stream(:tasks, Work.list_tasks(repository, scope: socket.assigns.current_scope),
|
||||
reset: true
|
||||
)
|
||||
|> assign(:task_target_options, task_target_options(scans))
|
||||
|> stream(:scans, scans, reset: true)
|
||||
end
|
||||
end
|
||||
|
||||
defp reload_tasks(socket) do
|
||||
tasks = Work.list_tasks(socket.assigns.repository, scope: socket.assigns.current_scope)
|
||||
stream(socket, :tasks, tasks, reset: true)
|
||||
end
|
||||
|
||||
# Raw reports are immutable provenance. Shared votes and checks are loaded
|
||||
# once for each canonical issue instead of being repeated on occurrences.
|
||||
defp load_scans(socket) do
|
||||
scans = Scans.list_scans(socket.assigns.current_scope, socket.assigns.repository)
|
||||
|
||||
findings =
|
||||
canonical_findings(
|
||||
socket.assigns.repository,
|
||||
socket.assigns.current_scope,
|
||||
current_account_id(socket)
|
||||
)
|
||||
|
||||
socket
|
||||
|> assign(:canonical_findings, findings)
|
||||
|> assign(:checkable_findings_count, Enum.count(findings, & &1.can_check))
|
||||
|> assign(:task_target_options, task_target_options(scans))
|
||||
|> stream(:scans, scans, reset: true)
|
||||
end
|
||||
|
||||
defp canonical_findings(repository, scope, account_id) do
|
||||
findings = FindingMemory.list_repository_memory(repository, limit: 200)
|
||||
|
||||
votes =
|
||||
Reputation.vote_summaries("canonical_finding", Enum.map(findings, & &1.id), account_id)
|
||||
|
||||
checkable_ids = FindingMemory.checkable_public_ids(scope, repository, findings)
|
||||
|
||||
findings
|
||||
|> Enum.map(fn finding ->
|
||||
Map.merge(finding, %{
|
||||
can_check: MapSet.member?(checkable_ids, finding.public_id),
|
||||
vote_summary: Map.get(votes, finding.id, %{score: 0, my_vote: 0})
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
defp current_account_id(%{assigns: %{current_scope: %{account: %{id: id}}}}), do: id
|
||||
defp current_account_id(_socket), do: nil
|
||||
|
||||
# phx-value params are client-controlled; never feed them to String.to_integer/1.
|
||||
defp normalize_id(id) when is_integer(id) and id > 0, do: {:ok, id}
|
||||
|
||||
defp normalize_id(id) when is_binary(id) do
|
||||
case Integer.parse(id) do
|
||||
{parsed, ""} when parsed > 0 -> {:ok, parsed}
|
||||
_other -> {:error, :invalid_id}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_id(_id), do: {:error, :invalid_id}
|
||||
|
||||
defp normalize_vote(value) when is_integer(value), do: {:ok, value}
|
||||
|
||||
defp normalize_vote(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{parsed, ""} -> {:ok, parsed}
|
||||
_other -> {:error, :invalid_vote}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_vote(_value), do: {:error, :invalid_vote}
|
||||
|
||||
defp can_vote?(socket), do: not is_nil(current_account_id(socket))
|
||||
|
||||
defp can_manage_disclosure?(socket, repository) do
|
||||
Policy.allowed?(socket.assigns.current_scope, :moderate, repository)
|
||||
end
|
||||
|
||||
defp create_task_from_params(socket, params) do
|
||||
params = normalize_task_params(params)
|
||||
|
||||
case Work.create_task(socket.assigns.repository, socket.assigns.current_scope, params) do
|
||||
{:ok, task} ->
|
||||
message =
|
||||
if task.status == "open" do
|
||||
"Job opened on the public queue."
|
||||
else
|
||||
"Job proposed for independent approval."
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:task_form, empty_task_form(socket.assigns.repository))
|
||||
|> assign(:show_task_form, false)
|
||||
|> stream_insert(:tasks, task, at: 0)
|
||||
|> put_flash(:info, message)}
|
||||
|
||||
{:error, :commit_not_found} ->
|
||||
{:noreply, assign_task_error(socket, params, :commit_sha, "commit was not found")}
|
||||
|
||||
{:error, :commit_mismatch} ->
|
||||
{:noreply,
|
||||
assign_task_error(socket, params, :commit_sha, "commit verification returned a mismatch")}
|
||||
|
||||
{:error, reason} when reason in [:rate_limited, :unavailable] ->
|
||||
{:noreply,
|
||||
assign_task_error(socket, params, :commit_sha, "commit could not be verified right now")}
|
||||
|
||||
{:error, reason} when reason in [:identity_changed, :not_public] ->
|
||||
{:noreply,
|
||||
assign_task_error(
|
||||
socket,
|
||||
params,
|
||||
:commit_sha,
|
||||
"repository identity is no longer confirmed public (GitHub-backed repos only - Tarakan-hosted repos use the local git object database)"
|
||||
)}
|
||||
|
||||
{:error, :proposal_limit} ->
|
||||
{:noreply, put_flash(socket, :error, "Daily review-task proposal limit reached.")}
|
||||
|
||||
{:error, :proposal_rate_limited} ->
|
||||
{:noreply, put_flash(socket, :error, "Too many task proposals. Try again shortly.")}
|
||||
|
||||
{:error, :duplicate_job} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
"An open job already covers this commit and job type. Claim or complete that one first."
|
||||
)}
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
{:noreply, put_flash(socket, :error, "This account cannot propose jobs.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_task_form, true)
|
||||
|> assign(:task_form, to_form(changeset, as: :review_task))}
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_task_error(socket, params, field, message) do
|
||||
filled = Work.fill_task_defaults(socket.assigns.repository, params)
|
||||
|
||||
form =
|
||||
%ReviewTask{}
|
||||
|> Work.change_task(filled)
|
||||
|> Ecto.Changeset.add_error(field, message)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form(as: :review_task)
|
||||
|
||||
socket
|
||||
|> assign(:show_task_form, true)
|
||||
|> assign(:task_form, form)
|
||||
end
|
||||
|
||||
defp empty_task_form(repository) do
|
||||
draft_task_form(repository, %{})
|
||||
end
|
||||
|
||||
defp draft_task_form(repository, params, opts \\ []) do
|
||||
params =
|
||||
params
|
||||
|> maybe_default_commit(repository)
|
||||
|> then(&Work.fill_task_defaults(repository, &1))
|
||||
|
||||
changeset =
|
||||
%ReviewTask{}
|
||||
|> Work.change_task(params)
|
||||
|
||||
changeset =
|
||||
case Keyword.get(opts, :action) do
|
||||
nil -> changeset
|
||||
action -> Map.put(changeset, :action, action)
|
||||
end
|
||||
|
||||
to_form(changeset, as: :review_task)
|
||||
end
|
||||
|
||||
defp load_branch_options(socket) do
|
||||
repository = socket.assigns.repository
|
||||
|
||||
case RepositoryCode.list_branches(repository) do
|
||||
{:ok, branches} ->
|
||||
default = repository.default_branch
|
||||
selected = socket.assigns[:selected_branch] || default || List.first(branches)
|
||||
|
||||
socket
|
||||
|> assign(:branch_options, branches)
|
||||
|> assign(:selected_branch, selected)
|
||||
|
||||
{:error, _} ->
|
||||
default = repository.default_branch
|
||||
|
||||
branches =
|
||||
if is_binary(default) and default != "", do: [default], else: []
|
||||
|
||||
socket
|
||||
|> assign(:branch_options, branches)
|
||||
|> assign(:selected_branch, default)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_default_commit(params, repository) do
|
||||
params = for {k, v} <- params, into: %{}, do: {to_string(k), v}
|
||||
|
||||
if present_param?(params["commit_sha"]) do
|
||||
params
|
||||
else
|
||||
case RepositoryCode.resolve_default_commit(repository) do
|
||||
{:ok, commit_sha} -> Map.put(params, "commit_sha", commit_sha)
|
||||
{:error, _} -> params
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp present_param?(value) when value in [nil, ""], do: false
|
||||
defp present_param?(value) when is_binary(value), do: String.trim(value) != ""
|
||||
defp present_param?(_), do: true
|
||||
|
||||
defp can_auto_open_job?(%{assigns: %{current_scope: scope}}, repository)
|
||||
when not is_nil(scope) do
|
||||
# publish_task is checked against the repository for stewards; moderators always can.
|
||||
Policy.allowed?(scope, :publish_task, repository) or
|
||||
Policy.allowed?(scope, :manage_repository, repository)
|
||||
end
|
||||
|
||||
defp can_auto_open_job?(_socket, _repository), do: false
|
||||
|
||||
defp can_cancel_job?(task, %{account: %{id: account_id}} = scope) when not is_nil(account_id) do
|
||||
task.status in ["proposed", "open", "claimed", "changes_requested"] and
|
||||
Policy.allowed?(scope, :cancel_task, task)
|
||||
end
|
||||
|
||||
defp can_cancel_job?(_task, _scope), do: false
|
||||
|
||||
defp can_moderate?(scan, %{account: account} = scope) when not is_nil(account) do
|
||||
Policy.allowed?(scope, :moderate_review, scan) and scan.submitted_by_id != account.id
|
||||
end
|
||||
|
||||
defp can_moderate?(_scan, _scope), do: false
|
||||
|
||||
defp moderation_form do
|
||||
to_form(
|
||||
%{
|
||||
"visibility" => "restricted",
|
||||
"moderation_reason" => "",
|
||||
"moderation_notes" => ""
|
||||
},
|
||||
as: :moderation
|
||||
)
|
||||
end
|
||||
|
||||
defp stringify_moderation_attrs(attrs) when is_map(attrs) do
|
||||
Map.new(attrs, fn {k, v} -> {to_string(k), v} end)
|
||||
end
|
||||
|
||||
defp stringify_moderation_attrs(_), do: %{}
|
||||
|
||||
# Stored on the scan for audit/activity feeds. Not a user-facing concept.
|
||||
defp default_moderation_reason("accept"), do: "evidence_reviewed"
|
||||
defp default_moderation_reason("reject"), do: "evidence_insufficient"
|
||||
defp default_moderation_reason("contest"), do: "independently_contested"
|
||||
defp default_moderation_reason("publish_full"), do: "disclosure_reviewed"
|
||||
defp default_moderation_reason("publish_summary"), do: "safe_summary"
|
||||
defp default_moderation_reason("restrict"), do: "takedown_review"
|
||||
defp default_moderation_reason(_), do: "moderator_decision"
|
||||
|
||||
defp with_recent_auth(socket, fun) do
|
||||
if Accounts.sudo_mode?(socket.assigns.current_scope.account) do
|
||||
fun.()
|
||||
else
|
||||
repo = socket.assigns.repository
|
||||
return_to = TarakanWeb.RepositoryPaths.repository_path(repo)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
"Confirm it's you with a magic link before changing review disclosure (sign-in older than 8 hours)."
|
||||
)
|
||||
|> push_navigate(to: TarakanWeb.AccountAuth.reauth_path(return_to))}
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_visible_scan(socket, scan, opts \\ []) do
|
||||
socket = reload_repository(socket)
|
||||
|
||||
socket =
|
||||
case Scans.get_scan(socket.assigns.current_scope, scan.id) do
|
||||
{:ok, visible_scan} -> stream_insert(socket, :scans, visible_scan, opts)
|
||||
{:error, :not_found} -> stream_delete(socket, :scans, scan)
|
||||
end
|
||||
|
||||
scans = Scans.list_scans(socket.assigns.current_scope, socket.assigns.repository)
|
||||
assign(socket, :task_target_options, task_target_options(scans))
|
||||
end
|
||||
|
||||
defp short_sha(sha), do: String.slice(sha, 0, 7)
|
||||
|
||||
defp scan_time(%DateTime{} = datetime) do
|
||||
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
|
||||
end
|
||||
|
||||
# Signal is reserved for findings; quiet states stay quiet.
|
||||
|
||||
defp finding_lines(%{line_start: nil}), do: ""
|
||||
defp finding_lines(%{line_start: line, line_end: line}), do: ":#{line}"
|
||||
|
||||
defp finding_lines(%{line_start: line_start, line_end: line_end}),
|
||||
do: ":#{line_start}-#{line_end}"
|
||||
|
||||
defp repository_meta_description(repository) do
|
||||
label = TarakanWeb.RepositoryComponents.repository_status_label(repository)
|
||||
host = repository.host || "github.com"
|
||||
|
||||
base =
|
||||
"Public security record for #{repository.owner}/#{repository.name} on #{host}. " <>
|
||||
"#{label}."
|
||||
|
||||
detail =
|
||||
cond do
|
||||
(repository.open_findings_count || 0) > 0 ->
|
||||
" See open findings and open jobs."
|
||||
|
||||
true ->
|
||||
" Contribute a review or claim an open job."
|
||||
end
|
||||
|
||||
String.slice(base <> detail, 0, 160)
|
||||
end
|
||||
|
||||
# Mass UI: Report job + Check job first; advanced kinds still available.
|
||||
defp task_kind_options do
|
||||
primary = [
|
||||
{"Security report (findings)", "code_review"},
|
||||
{"Check an existing report", "verify_findings"}
|
||||
]
|
||||
|
||||
advanced =
|
||||
~w(diff_review threat_model privacy_review business_logic write_fix)
|
||||
|> Enum.map(&{review_kind_label(&1) <> " (advanced)", &1})
|
||||
|
||||
primary ++ advanced
|
||||
end
|
||||
|
||||
defp task_target_options(scans) do
|
||||
scans
|
||||
|> Enum.filter(&(&1.findings_count > 0))
|
||||
|> Enum.map(fn scan ->
|
||||
count_label =
|
||||
if scan.findings_count == 1, do: "1 finding", else: "#{scan.findings_count} findings"
|
||||
|
||||
{
|
||||
"Report ##{scan.id} · #{review_kind_label(scan.review_kind)} · #{short_sha(scan.commit_sha)} · #{count_label}",
|
||||
scan.id
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
# Each kind carries exactly one extra field. Leaving a stale value from a
|
||||
# different kind in the params makes the changeset reject it, which reads to
|
||||
# the user as an unrelated validation error.
|
||||
defp normalize_task_params(%{"kind" => "verify_findings"} = params) do
|
||||
Map.delete(params, "base_commit_sha")
|
||||
end
|
||||
|
||||
defp normalize_task_params(%{"kind" => "diff_review"} = params) do
|
||||
Map.delete(params, "target_review_id")
|
||||
end
|
||||
|
||||
defp normalize_task_params(params) do
|
||||
params |> Map.delete("target_review_id") |> Map.delete("base_commit_sha")
|
||||
end
|
||||
|
||||
# Job form: only who should pick it up. Hybrid stays a report provenance
|
||||
# (agent draft a person edited), not a separate "human-guided" work mode.
|
||||
defp capability_options do
|
||||
[
|
||||
{"AI helper", "agent"},
|
||||
{"Human only", "human"}
|
||||
]
|
||||
end
|
||||
|
||||
defp short_task_status(%{status: "claimed"} = task) do
|
||||
if Tarakan.Work.ReviewTask.claim_active?(task), do: "Claimed", else: "Open"
|
||||
end
|
||||
|
||||
defp short_task_status(%{status: "changes_requested"}), do: "Changes requested"
|
||||
|
||||
defp short_task_status(%{status: status}), do: String.capitalize(status)
|
||||
|
||||
defp empty_review_label(%{review_kind: "code_review"}), do: "No findings reported"
|
||||
defp empty_review_label(_scan), do: "Reviewed"
|
||||
end
|
||||
687
lib/tarakan_web/live/repository_live/show.html.heex
Normal file
687
lib/tarakan_web/live/repository_live/show.html.heex
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.repository_header repository={@repository} active_tab={:security} />
|
||||
|
||||
<TarakanWeb.BountyComponents.wanted_banner bounties={@open_bounties} />
|
||||
|
||||
<div
|
||||
:if={@can_manage_disclosure}
|
||||
id="managed-disclosure-controls"
|
||||
class="mt-4 flex flex-wrap items-center gap-3 border border-rule bg-panel px-4 py-3"
|
||||
>
|
||||
<p class="text-xs text-ink-muted">
|
||||
Managed disclosure is
|
||||
<span class="font-semibold text-ink">
|
||||
{if @repository.managed_disclosure, do: "on", else: "off"}
|
||||
</span>
|
||||
for this repository. Staff triage findings with the vendor; the public record is unchanged.
|
||||
</p>
|
||||
<button
|
||||
id="toggle-managed-disclosure"
|
||||
type="button"
|
||||
phx-click="toggle_managed_disclosure"
|
||||
class="ml-auto border border-strong px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink transition hover:border-quote hover:text-quote"
|
||||
>
|
||||
{if @repository.managed_disclosure, do: "Disable", else: "Enable"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- At a glance --%>
|
||||
<section
|
||||
id="security-summary"
|
||||
class="mt-6 grid border-2 border-strong sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<div class="border-b border-rule px-4 py-4 lg:border-b-0 sm:border-r">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
Open findings
|
||||
</p>
|
||||
<p id="open-findings-count" class="mt-1 font-display text-3xl tabular-nums text-ink">
|
||||
{@repository.open_findings_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-rule px-4 py-4 lg:border-b-0 lg:border-r">
|
||||
<p class="text-xs font-medium text-ink-faint">Verified</p>
|
||||
<p id="verified-findings-count" class="mt-1 font-display text-3xl tabular-nums text-ink">
|
||||
{@repository.verified_findings_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-rule px-4 py-4 sm:border-r lg:border-b-0">
|
||||
<p class="text-xs font-medium text-ink-faint">Reports</p>
|
||||
<p id="scan-count" class="mt-1 font-display text-3xl tabular-nums text-ink">
|
||||
{@repository.scan_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-4 py-4">
|
||||
<p class="text-xs font-medium text-ink-faint">Median fix</p>
|
||||
<p id="median-time-to-fix" class="mt-1 font-display text-3xl tabular-nums text-ink">
|
||||
{Tarakan.SecurityPosture.format_days(@posture.median_days_to_fix)}
|
||||
</p>
|
||||
<p
|
||||
:if={@posture.oldest_open_days}
|
||||
class="mt-1 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
|
||||
>
|
||||
oldest open {Tarakan.SecurityPosture.format_days(@posture.oldest_open_days)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Fixed, then back. Only rendered when it has actually happened. --%>
|
||||
<section
|
||||
:if={@regressions != []}
|
||||
id="finding-regressions"
|
||||
class="mt-6 border-2 border-signal"
|
||||
>
|
||||
<h2 class="border-b border-rule px-4 py-3 font-display text-sm uppercase tracking-[0.12em] text-signal">
|
||||
Regressed - {@posture.regressions_count} fixed finding(s) came back
|
||||
</h2>
|
||||
<ul class="divide-y divide-rule">
|
||||
<li
|
||||
:for={regression <- @regressions}
|
||||
id={"regression-#{regression.id}"}
|
||||
class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3"
|
||||
>
|
||||
<.link
|
||||
navigate={~p"/findings/#{regression.canonical_finding.public_id}"}
|
||||
class="min-w-0 flex-1 truncate text-sm text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{regression.canonical_finding.title}
|
||||
</.link>
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
fixed at {String.slice(regression.fixed_commit_sha || "", 0, 7)} · back at {String.slice(
|
||||
regression.detected_commit_sha,
|
||||
0,
|
||||
7
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<%!-- Findings first: what matters --%>
|
||||
<section id="canonical-findings" class="mt-10">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3 border-b-2 border-strong pb-3">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Findings
|
||||
</h2>
|
||||
<span class="font-mono text-[11px] text-ink-faint">
|
||||
{@repository.open_findings_count} unique open
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
:if={@checkable_findings_count > 0}
|
||||
id="bulk-check-findings-form"
|
||||
phx-submit="bulk_check_findings"
|
||||
class="mt-4 border border-strong bg-panel px-4 py-4"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-ink">
|
||||
Check all ({@checkable_findings_count})
|
||||
</p>
|
||||
<input
|
||||
id="bulk-check-notes"
|
||||
type="text"
|
||||
name="notes"
|
||||
required
|
||||
minlength="20"
|
||||
maxlength="2000"
|
||||
placeholder="Independent evidence notes (required, 20+ chars)"
|
||||
autocomplete="off"
|
||||
class="mt-3 w-full border border-strong bg-ground px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:border-signal focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
<.button name="verdict" value="confirmed" size="sm">Confirm all</.button>
|
||||
<.button name="verdict" value="disputed" variant="danger" size="sm">
|
||||
Dispute all
|
||||
</.button>
|
||||
<.button name="verdict" value="fixed" size="sm">Fixed all</.button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
:if={@canonical_findings == []}
|
||||
id="findings-empty"
|
||||
class="border-b border-rule px-1 py-10 text-center"
|
||||
>
|
||||
<p class="text-sm font-medium text-ink">No findings yet</p>
|
||||
<p class="mt-1 text-xs leading-5 text-ink-muted">
|
||||
<code class="font-mono text-ink">tarakan worker --agent kimi</code> or open a Job below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :if={@canonical_findings != []} class="divide-y divide-rule border-b border-rule">
|
||||
<article
|
||||
:for={finding <- @canonical_findings}
|
||||
id={"canonical-#{finding.public_id}"}
|
||||
class="py-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start gap-3">
|
||||
<.vote_control
|
||||
subject_type="canonical_finding"
|
||||
subject_id={finding.id}
|
||||
summary={finding.vote_summary}
|
||||
can_vote={@can_vote}
|
||||
class="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-signal">
|
||||
{finding.severity}
|
||||
</span>
|
||||
<.notch_badge class={[
|
||||
finding.status == "verified" && "bg-ink text-ground",
|
||||
finding.status == "fixed" && "bg-quote text-ground",
|
||||
finding.status in ["open", "disputed"] && "text-ink-muted"
|
||||
]}>
|
||||
{finding.status}
|
||||
</.notch_badge>
|
||||
<.notch_badge
|
||||
:if={finding.trust && finding.trust.agent_reproduced}
|
||||
class="text-ink-muted"
|
||||
>
|
||||
Agent reproduced
|
||||
</.notch_badge>
|
||||
<.notch_badge
|
||||
:if={finding.trust && finding.trust.human_checked}
|
||||
class="text-ink-muted"
|
||||
>
|
||||
Human checked
|
||||
</.notch_badge>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-1.5 text-base font-semibold leading-snug text-ink">
|
||||
<.link
|
||||
navigate={~p"/findings/#{finding.occurrence_public_id}"}
|
||||
class="transition hover:text-signal"
|
||||
>
|
||||
{finding.title}
|
||||
</.link>
|
||||
</h3>
|
||||
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-faint">
|
||||
<span class="text-ink-muted">{finding.file_path}{finding_lines(finding)}</span>
|
||||
<span class="mx-1.5" aria-hidden="true">·</span>
|
||||
last at {short_sha(finding.last_seen_commit_sha)}
|
||||
</p>
|
||||
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-ink-muted">
|
||||
{TarakanWeb.FindingPresentation.description_excerpt(finding.description, 220)}
|
||||
</p>
|
||||
|
||||
<p class="mt-2 font-mono text-[10px] text-ink-faint">
|
||||
detected in {finding.detections_count} {if finding.detections_count == 1,
|
||||
do: "run",
|
||||
else: "runs"} · {finding.distinct_submitters_count} {if finding.distinct_submitters_count ==
|
||||
1,
|
||||
do: "submitter",
|
||||
else: "submitters"} · {finding.confirmations_count} confirmed · {finding.disputes_count} disputed
|
||||
</p>
|
||||
|
||||
<details :if={finding.can_check} class="mt-3">
|
||||
<summary class="cursor-pointer font-mono text-[11px] text-signal transition hover:underline">
|
||||
Override this finding only
|
||||
</summary>
|
||||
<form
|
||||
id={"canonical-#{finding.public_id}-check-form"}
|
||||
phx-submit="record_finding_verdict"
|
||||
class="mt-2 flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<input type="hidden" name="finding_id" value={finding.public_id} />
|
||||
<input type="hidden" name="commit_sha" value={finding.last_seen_commit_sha} />
|
||||
<input
|
||||
type="text"
|
||||
name="notes"
|
||||
required
|
||||
minlength="20"
|
||||
maxlength="2000"
|
||||
placeholder="Notes for this finding only"
|
||||
autocomplete="off"
|
||||
class="min-w-0 flex-1 basis-64 border border-rule bg-transparent px-3 py-1.5 font-mono text-xs text-ink placeholder:text-ink-faint focus:outline-none focus:ring-1 focus:ring-phosphor"
|
||||
/>
|
||||
<.button name="verdict" value="confirmed" size="sm">Confirm</.button>
|
||||
<.button name="verdict" value="disputed" variant="danger" size="sm">
|
||||
Dispute
|
||||
</.button>
|
||||
<.button name="verdict" value="fixed" size="sm">Fixed</.button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Jobs: action --%>
|
||||
<section id="review-work" class="mt-10">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3 border-b-2 border-strong pb-3">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Jobs
|
||||
</h2>
|
||||
<.link
|
||||
navigate={~p"/jobs"}
|
||||
class="font-mono text-[10px] uppercase tracking-[0.12em] text-signal hover:underline"
|
||||
>
|
||||
All open jobs →
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 border border-strong bg-panel px-4 py-4">
|
||||
<%= if @current_scope && @current_scope.account do %>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<.button
|
||||
id="quick-open-job-button"
|
||||
type="button"
|
||||
variant="primary"
|
||||
phx-click="quick_open_job"
|
||||
class="w-full phx-click-loading:opacity-60 sm:w-auto"
|
||||
>
|
||||
{if @can_auto_open_job,
|
||||
do: "Open security report job",
|
||||
else: "Propose security report job"}
|
||||
</.button>
|
||||
<button
|
||||
id="propose-task-toggle"
|
||||
type="button"
|
||||
phx-click="toggle_task_form"
|
||||
class="self-start py-1 font-mono text-xs text-signal transition hover:underline sm:self-auto"
|
||||
>
|
||||
{if @show_task_form, do: "Cancel", else: "Customize…"}
|
||||
</button>
|
||||
</div>
|
||||
<p :if={!@can_auto_open_job} class="mt-2 max-w-xl text-xs leading-5 text-ink-muted">
|
||||
A steward publishes proposals before they go live.
|
||||
</p>
|
||||
|
||||
<.form
|
||||
:if={@show_task_form}
|
||||
for={@task_form}
|
||||
id="review-task-form"
|
||||
phx-change="validate_task"
|
||||
phx-submit="create_task"
|
||||
class="mt-5 border-t border-rule pt-5"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
for="task-branch-select"
|
||||
class="mb-1.5 block text-sm font-semibold text-ink"
|
||||
>
|
||||
Branch
|
||||
</label>
|
||||
<select
|
||||
id="task-branch-select"
|
||||
name="branch"
|
||||
phx-change="select_branch"
|
||||
class="w-full border border-strong bg-ground px-3 py-2 font-mono text-sm text-ink focus:border-signal focus:outline-none"
|
||||
>
|
||||
<option
|
||||
:for={branch <- @branch_options}
|
||||
value={branch}
|
||||
selected={branch == @selected_branch}
|
||||
>
|
||||
{branch}{if branch == @repository.default_branch, do: " (default)", else: ""}
|
||||
</option>
|
||||
<option :if={@branch_options == []} value="" selected>
|
||||
{if @repository.default_branch,
|
||||
do: @repository.default_branch,
|
||||
else: "No branches listed"}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<.input
|
||||
field={@task_form[:commit_sha]}
|
||||
type="text"
|
||||
label="Commit SHA"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
id="use-default-commit-button"
|
||||
type="button"
|
||||
phx-click="use_default_commit"
|
||||
class="mt-1.5 font-mono text-[11px] text-signal transition hover:underline"
|
||||
>
|
||||
Refresh tip of {@selected_branch || @repository.default_branch || "branch"}
|
||||
</button>
|
||||
</div>
|
||||
<.input
|
||||
field={@task_form[:kind]}
|
||||
type="select"
|
||||
label="Job type"
|
||||
options={@task_kind_options}
|
||||
/>
|
||||
<%!-- diff_review reviews base..head, so it needs the other end of
|
||||
the range. Everything else reviews the snapshot at commit_sha. --%>
|
||||
<div :if={@task_form[:kind].value == "diff_review"}>
|
||||
<.input
|
||||
field={@task_form[:base_commit_sha]}
|
||||
type="text"
|
||||
label="Base commit (reviews base..commit)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div :if={@task_form[:kind].value == "verify_findings"}>
|
||||
<.input
|
||||
field={@task_form[:target_review_id]}
|
||||
type="select"
|
||||
label="Report to check"
|
||||
options={[{"Choose a report", ""} | @task_target_options]}
|
||||
/>
|
||||
<p
|
||||
:if={@task_target_options == []}
|
||||
class="mt-1.5 text-xs leading-5 text-ink-faint"
|
||||
>
|
||||
No reports with findings to check yet.
|
||||
</p>
|
||||
</div>
|
||||
<.input
|
||||
field={@task_form[:capability]}
|
||||
type="select"
|
||||
label="Who can do it"
|
||||
options={@capability_options}
|
||||
/>
|
||||
<.input
|
||||
field={@task_form[:title]}
|
||||
type="text"
|
||||
label="Title (optional)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<.input
|
||||
field={@task_form[:description]}
|
||||
type="textarea"
|
||||
label="What to do (optional)"
|
||||
placeholder="Leave blank for an auto brief, or add focus."
|
||||
/>
|
||||
</div>
|
||||
<.button
|
||||
id="create-review-task-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
class="mt-4 phx-submit-loading:opacity-60"
|
||||
>
|
||||
{if @can_auto_open_job, do: "Open job", else: "Propose job"}
|
||||
</.button>
|
||||
</.form>
|
||||
<% else %>
|
||||
<div id="review-task-auth-gate">
|
||||
<p class="text-sm text-ink-muted">Sign in to open or claim jobs on this repo.</p>
|
||||
<.link
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="mt-2 inline-block font-mono text-xs text-signal hover:underline"
|
||||
>
|
||||
Sign in →
|
||||
</.link>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="review-tasks"
|
||||
phx-update="stream"
|
||||
class="mt-4 divide-y divide-rule border-t border-rule"
|
||||
>
|
||||
<div
|
||||
id="review-tasks-empty"
|
||||
class="hidden py-8 text-center text-sm text-ink-faint only:block"
|
||||
>
|
||||
No jobs yet. Open one above or run <code class="font-mono text-ink">tarakan worker --agent kimi</code>.
|
||||
</div>
|
||||
<article
|
||||
:for={{id, task} <- @streams.tasks}
|
||||
id={id}
|
||||
class="grid gap-3 px-1 py-4 transition-colors hover:bg-panel sm:grid-cols-[1fr_auto] sm:items-center sm:px-2"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<span class="text-ink-muted">{review_kind_label(task.kind)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{provenance_label(task.capability)} required</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span title={task.commit_sha}>{short_sha(task.commit_sha)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span class="text-ink">{short_task_status(task)}</span>
|
||||
</div>
|
||||
<h3 class="mt-1.5 text-sm font-semibold text-ink">{task.title}</h3>
|
||||
<p class="mt-1 line-clamp-2 text-xs leading-5 text-ink-muted">{task.description}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<.button
|
||||
id={"review-task-#{task.id}-link"}
|
||||
navigate={~p"/jobs/#{task.id}"}
|
||||
size="sm"
|
||||
>
|
||||
Open
|
||||
</.button>
|
||||
<.button
|
||||
:if={can_cancel_job?(task, @current_scope)}
|
||||
id={"review-task-#{task.id}-cancel"}
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
phx-click="cancel_task"
|
||||
phx-value-id={task.id}
|
||||
data-confirm="Cancel this job? It will leave the open queue."
|
||||
>
|
||||
Cancel
|
||||
</.button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Raw reports last: provenance --%>
|
||||
<section id="security-record" class="mt-10">
|
||||
<div class="border-b-2 border-strong pb-3">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Reports
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div id="scans" phx-update="stream" class="border-b border-rule">
|
||||
<div
|
||||
id="scans-empty"
|
||||
class="hidden px-1 py-10 text-center text-sm text-ink-faint only:block"
|
||||
>
|
||||
No reports yet.
|
||||
</div>
|
||||
|
||||
<article :for={{id, scan} <- @streams.scans} id={id} class="border-b border-rule py-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<.handle_link
|
||||
handle={scan.submitted_by.handle}
|
||||
class="text-sm font-semibold text-ink"
|
||||
/>
|
||||
<span
|
||||
id={"scan-#{scan.id}-provenance-attestation"}
|
||||
class="font-mono text-[11px] text-ink-muted"
|
||||
title="Submitter claim, not independently verified"
|
||||
>
|
||||
{TarakanWeb.FindingPresentation.how_made_label(scan.provenance)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-faint">
|
||||
{review_kind_label(scan.review_kind)}
|
||||
<span class="mx-1" aria-hidden="true">·</span>
|
||||
<span title={scan.review_status}>
|
||||
{TarakanWeb.FindingPresentation.status_blurb(scan.review_status)}
|
||||
</span>
|
||||
<span class="mx-1" aria-hidden="true">·</span>
|
||||
<span title={scan.commit_sha}>{short_sha(scan.commit_sha)}</span>
|
||||
<span class="mx-1" aria-hidden="true">·</span>
|
||||
{scan_time(scan.inserted_at)}
|
||||
</p>
|
||||
</div>
|
||||
<.notch_badge :if={scan.findings_count > 0} class="shrink-0 text-signal">
|
||||
{scan.findings_count}
|
||||
{if scan.findings_count == 1, do: "finding", else: "findings"}
|
||||
</.notch_badge>
|
||||
<.notch_badge :if={scan.findings_count == 0} class="shrink-0 text-ink-muted">
|
||||
{empty_review_label(scan)}
|
||||
</.notch_badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={scan.details_visible && (scan.model || scan.prompt_version)}
|
||||
class="mt-2 font-mono text-[11px] text-ink-faint"
|
||||
>
|
||||
<span :if={scan.model}>via {scan.model}</span>
|
||||
<span :if={scan.model && scan.prompt_version}> · </span>
|
||||
<span :if={scan.prompt_version}>{scan.prompt_version}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={scan.details_visible && scan.notes}
|
||||
class="mt-3 max-w-3xl text-sm leading-6 text-ink-muted"
|
||||
>
|
||||
<% notes = TarakanWeb.FindingPresentation.humanize_notes(scan.notes) %>
|
||||
<p :if={notes && notes.kind == :summary} class="text-ink-muted">
|
||||
{notes.count} findings
|
||||
<span :if={notes.tops != []}>
|
||||
· top: {Enum.join(Enum.take(notes.tops, 2), "; ")}
|
||||
</span>
|
||||
</p>
|
||||
<p :if={notes && notes.kind == :plain}>{notes.text}</p>
|
||||
</div>
|
||||
|
||||
<details
|
||||
:if={scan.details_visible && scan.findings_count > 0}
|
||||
class="mt-3"
|
||||
open={scan.findings_count <= 3}
|
||||
>
|
||||
<summary class="cursor-pointer font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
|
||||
Findings in this report ({scan.findings_count})
|
||||
</summary>
|
||||
<ul class="mt-3 space-y-3 border-l-2 border-rule pl-4">
|
||||
<li :for={finding <- scan.findings}>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-signal">
|
||||
{finding.severity}
|
||||
</span>
|
||||
<.notch_badge
|
||||
:if={
|
||||
finding.canonical_finding && finding.canonical_finding.detections_count > 1
|
||||
}
|
||||
class="text-ink-muted"
|
||||
>
|
||||
seen in {finding.canonical_finding.detections_count} runs
|
||||
</.notch_badge>
|
||||
<.link
|
||||
id={"finding-source-#{finding.id}"}
|
||||
navigate={~p"/findings/#{finding.public_id}/code"}
|
||||
class="font-mono text-[11px] text-ink-faint transition hover:text-ink"
|
||||
aria-label={"Open #{finding.file_path} at the finding's pinned commit"}
|
||||
>
|
||||
{finding.file_path}{finding_lines(finding)}
|
||||
</.link>
|
||||
</div>
|
||||
<h4 class="mt-0.5 text-sm font-semibold text-ink">
|
||||
<.link
|
||||
id={"finding-page-#{finding.id}"}
|
||||
navigate={~p"/findings/#{finding.public_id}"}
|
||||
class="transition hover:text-signal"
|
||||
>
|
||||
{finding.title}
|
||||
</.link>
|
||||
</h4>
|
||||
<p class="mt-1 max-w-3xl text-sm leading-6 text-ink-muted">
|
||||
{TarakanWeb.FindingPresentation.description_excerpt(finding.description, 180)}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details
|
||||
:if={scan.details_visible && scan.raw_document}
|
||||
class="mt-2"
|
||||
>
|
||||
<summary class="cursor-pointer font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
|
||||
Raw JSON
|
||||
</summary>
|
||||
<pre
|
||||
id={"scan-#{scan.id}-raw-report"}
|
||||
class="mt-2 max-h-48 overflow-auto border border-rule bg-panel px-3 py-2 font-mono text-[11px] leading-5 text-ink-muted"
|
||||
>{raw_report(scan)}</pre>
|
||||
</details>
|
||||
|
||||
<p
|
||||
:if={!scan.details_visible}
|
||||
class="mt-3 text-xs leading-5 text-ink-faint"
|
||||
>
|
||||
Detailed evidence is restricted.
|
||||
</p>
|
||||
|
||||
<details
|
||||
:if={can_moderate?(scan, @current_scope)}
|
||||
class="mt-4 border-l-2 border-rule pl-4"
|
||||
>
|
||||
<summary class="cursor-pointer text-sm font-semibold text-ink transition hover:text-signal">
|
||||
Moderate this report
|
||||
</summary>
|
||||
<.form
|
||||
for={@moderation_form}
|
||||
id={"scan-#{scan.id}-moderation-form"}
|
||||
phx-submit="moderate_scan"
|
||||
class="mt-3"
|
||||
>
|
||||
<input type="hidden" name="scan_id" value={scan.id} />
|
||||
<.input
|
||||
id={"scan-#{scan.id}-moderation-notes"}
|
||||
field={@moderation_form[:moderation_notes]}
|
||||
type="textarea"
|
||||
label="What did you check?"
|
||||
placeholder="What was independently verified (20+ characters)."
|
||||
/>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<.button
|
||||
:if={scan.review_status != "accepted"}
|
||||
name="decision"
|
||||
value="accept"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Accept
|
||||
</.button>
|
||||
<.button name="decision" value="contest" size="sm">Contest</.button>
|
||||
<.button name="decision" value="reject" variant="danger" size="sm">
|
||||
Reject
|
||||
</.button>
|
||||
<.button
|
||||
:if={scan.visibility != "public"}
|
||||
name="decision"
|
||||
value="publish_full"
|
||||
size="sm"
|
||||
>
|
||||
Restore full evidence
|
||||
</.button>
|
||||
<.button
|
||||
:if={scan.visibility == "public"}
|
||||
name="decision"
|
||||
value="publish_summary"
|
||||
size="sm"
|
||||
>
|
||||
Redact to summary
|
||||
</.button>
|
||||
<.button
|
||||
:if={scan.visibility != "restricted"}
|
||||
name="decision"
|
||||
value="restrict"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
Restrict
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
Loading…
Add table
Add a link
Reference in a new issue