neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
Add an instance-wide /syms ranking
34f99c6 · Gabriel Morell · 2026-09-09 21:32
Message
{commit_body(@commit)}
Files changed
modified
config/config.exs
+3
−1
@@ -31,7 +31,9 @@ config :git_gud,
| 31 | 31 | # stars, but a set). Redefine per instance; an empty list disables the |
| 32 | 32 | # feature entirely. Removing a symbol only hides it: existing marks |
| 33 | 33 | # stay in the database and come back if it is re-added. |
| 34 | − syms: ["⭐", "🚀", "💔", "⁉️", "❤️"] | |
| 34 | + syms: ["⭐", "🚀", "💔", "⁉️", "❤️"], | |
| 35 | + # How many repositories the instance-wide /syms listings rank. | |
| 36 | + syms_top_limit: 100 | |
| 35 | 37 | |
| 36 | 38 | # Oban for background jobs (cache backfill, webhook delivery, CI dispatch, |
| 37 | 39 | # federation retention). |
modified
lib/git_gud/syms.ex
+125
−0
@@ -102,6 +102,131 @@ defmodule GitGud.Syms do
| 102 | 102 | |> Map.new() |
| 103 | 103 | end |
| 104 | 104 | |
| 105 | + @doc """ | |
| 106 | + How many repositories the instance-wide listings rank. Config entry | |
| 107 | + `:syms_top_limit`, default 100. | |
| 108 | + """ | |
| 109 | + def top_limit do | |
| 110 | + case Application.get_env(:git_gud, :syms_top_limit, 100) do | |
| 111 | + n when is_integer(n) and n > 0 -> n | |
| 112 | + _ -> 100 | |
| 113 | + end | |
| 114 | + end | |
| 115 | + | |
| 116 | + @doc """ | |
| 117 | + The instance's most-marked repositories, most marks first. | |
| 118 | + | |
| 119 | + `viewer` is the user looking (or `nil`). Repositories they may not | |
| 120 | + read are excluded *in the query*, before the limit — filtering | |
| 121 | + afterwards would punch holes in the top N. | |
| 122 | + | |
| 123 | + Each entry is `%{repository: repo, total: n, counts: %{symbol => n}}`. | |
| 124 | + """ | |
| 125 | + def top_repositories(viewer, limit \\ nil) do | |
| 126 | + limit = limit || top_limit() | |
| 127 | + current = symbols() | |
| 128 | + | |
| 129 | + if current == [] do | |
| 130 | + [] | |
| 131 | + else | |
| 132 | + rows = | |
| 133 | + from(s in RepositorySym, | |
| 134 | + join: r in Repository, | |
| 135 | + on: r.id == s.repository_id, | |
| 136 | + where: s.symbol in ^current, | |
| 137 | + where: ^visibility_filter(viewer), | |
| 138 | + group_by: s.repository_id, | |
| 139 | + order_by: [desc: count(s.id), asc: s.repository_id], | |
| 140 | + limit: ^limit, | |
| 141 | + select: {s.repository_id, count(s.id)} | |
| 142 | + ) | |
| 143 | + |> Repo.all() | |
| 144 | + | |
| 145 | + decorate_ranking(rows, current) | |
| 146 | + end | |
| 147 | + end | |
| 148 | + | |
| 149 | + @doc """ | |
| 150 | + The most-marked repositories for one symbol. Same visibility rules as | |
| 151 | + `top_repositories/2`; `[]` for a symbol the instance doesn't configure. | |
| 152 | + """ | |
| 153 | + def top_for_symbol(symbol, viewer, limit \\ nil) do | |
| 154 | + limit = limit || top_limit() | |
| 155 | + | |
| 156 | + if known?(symbol) do | |
| 157 | + rows = | |
| 158 | + from(s in RepositorySym, | |
| 159 | + join: r in Repository, | |
| 160 | + on: r.id == s.repository_id, | |
| 161 | + where: s.symbol == ^symbol, | |
| 162 | + where: ^visibility_filter(viewer), | |
| 163 | + group_by: s.repository_id, | |
| 164 | + order_by: [desc: count(s.id), asc: s.repository_id], | |
| 165 | + limit: ^limit, | |
| 166 | + select: {s.repository_id, count(s.id)} | |
| 167 | + ) | |
| 168 | + |> Repo.all() | |
| 169 | + | |
| 170 | + decorate_ranking(rows, symbols()) | |
| 171 | + else | |
| 172 | + [] | |
| 173 | + end | |
| 174 | + end | |
| 175 | + | |
| 176 | + @doc "Instance-wide count per symbol, across everything readable." | |
| 177 | + def global_counts(viewer) do | |
| 178 | + current = symbols() | |
| 179 | + | |
| 180 | + from(s in RepositorySym, | |
| 181 | + join: r in Repository, | |
| 182 | + on: r.id == s.repository_id, | |
| 183 | + where: s.symbol in ^current, | |
| 184 | + where: ^visibility_filter(viewer), | |
| 185 | + group_by: s.symbol, | |
| 186 | + select: {s.symbol, count(s.id)} | |
| 187 | + ) | |
| 188 | + |> Repo.all() | |
| 189 | + |> Map.new() | |
| 190 | + end | |
| 191 | + | |
| 192 | + # Anonymous visitors see public repos; a signed-in user also sees | |
| 193 | + # internal ones and their own private repos. Mirrors the read rules | |
| 194 | + # used elsewhere — org membership and team grants are not yet | |
| 195 | + # reflected here, so a private org repo stays out of the ranking. | |
| 196 | + defp visibility_filter(%User{id: uid}) do | |
| 197 | + dynamic([_s, r], r.visibility in ["public", "internal"] or r.owner_id == ^uid) | |
| 198 | + end | |
| 199 | + | |
| 200 | + defp visibility_filter(_), do: dynamic([_s, r], r.visibility == "public") | |
| 201 | + | |
| 202 | + # One extra query for the per-symbol breakdown of the ranked repos, | |
| 203 | + # rather than one per row. | |
| 204 | + defp decorate_ranking([], _current), do: [] | |
| 205 | + | |
| 206 | + defp decorate_ranking(rows, current) do | |
| 207 | + ids = Enum.map(rows, &elem(&1, 0)) | |
| 208 | + | |
| 209 | + breakdown = | |
| 210 | + from(s in RepositorySym, | |
| 211 | + where: s.repository_id in ^ids and s.symbol in ^current, | |
| 212 | + group_by: [s.repository_id, s.symbol], | |
| 213 | + select: {s.repository_id, s.symbol, count(s.id)} | |
| 214 | + ) | |
| 215 | + |> Repo.all() | |
| 216 | + |> Enum.group_by(&elem(&1, 0), fn {_, sym, n} -> {sym, n} end) | |
| 217 | + |> Map.new(fn {rid, pairs} -> {rid, Map.new(pairs)} end) | |
| 218 | + | |
| 219 | + repos = | |
| 220 | + from(r in Repository, where: r.id in ^ids) | |
| 221 | + |> Repo.all() | |
| 222 | + |> Repo.preload([:owner, :organization]) | |
| 223 | + |> Map.new(&{&1.id, &1}) | |
| 224 | + | |
| 225 | + for {rid, total} <- rows, repo = repos[rid] do | |
| 226 | + %{repository: repo, total: total, counts: Map.get(breakdown, rid, %{})} | |
| 227 | + end | |
| 228 | + end | |
| 229 | + | |
| 105 | 230 | @doc """ |
| 106 | 231 | Repositories `user` marked with `symbol`, newest mark first. |
| 107 | 232 |
added
lib/git_gud_web/live/syms_live/top.ex
+111
−0
@@ -0,0 +1,111 @@
| 1 | +defmodule GitGudWeb.SymsLive.Top do | |
| 2 | + @moduledoc """ | |
| 3 | + The instance-wide syms listing. | |
| 4 | + | |
| 5 | + `/syms` ranks the most-marked repositories overall; `/syms/:sym` ranks | |
| 6 | + them for one symbol. Both are scoped to what the viewer may read, and | |
| 7 | + that filtering happens inside the query so the top N is never short. | |
| 8 | + """ | |
| 9 | + use GitGudWeb, :live_view | |
| 10 | + | |
| 11 | + alias GitGud.Repositories.Storage | |
| 12 | + alias GitGud.Syms | |
| 13 | + | |
| 14 | + @impl true | |
| 15 | + def mount(params, _session, socket) do | |
| 16 | + viewer = socket.assigns.current_scope && socket.assigns.current_scope.user | |
| 17 | + sym = params["sym"] | |
| 18 | + | |
| 19 | + cond do | |
| 20 | + is_nil(sym) -> | |
| 21 | + {:ok, | |
| 22 | + socket | |
| 23 | + |> assign_common(viewer, nil) | |
| 24 | + |> assign(:entries, Syms.top_repositories(viewer)) | |
| 25 | + |> assign(:page_title, "syms")} | |
| 26 | + | |
| 27 | + Syms.known?(sym) -> | |
| 28 | + {:ok, | |
| 29 | + socket | |
| 30 | + |> assign_common(viewer, sym) | |
| 31 | + |> assign(:entries, Syms.top_for_symbol(sym, viewer)) | |
| 32 | + |> assign(:page_title, "syms: #{sym}")} | |
| 33 | + | |
| 34 | + true -> | |
| 35 | + {:ok, | |
| 36 | + socket | |
| 37 | + |> put_flash(:error, "No such symbol on this instance.") | |
| 38 | + |> push_navigate(to: ~p"/syms")} | |
| 39 | + end | |
| 40 | + end | |
| 41 | + | |
| 42 | + defp assign_common(socket, viewer, sym) do | |
| 43 | + socket | |
| 44 | + |> assign(:symbols, Syms.symbols()) | |
| 45 | + |> assign(:sym, sym) | |
| 46 | + |> assign(:limit, Syms.top_limit()) | |
| 47 | + |> assign(:global_counts, Syms.global_counts(viewer)) | |
| 48 | + end | |
| 49 | + | |
| 50 | + @impl true | |
| 51 | + def render(assigns) do | |
| 52 | + ~H""" | |
| 53 | + <Layouts.app flash={@flash} current_scope={@current_scope}> | |
| 54 | + <div class="space-y-4 max-w-3xl"> | |
| 55 | + <h1 class="text-xl font-semibold flex items-center gap-2"> | |
| 56 | + <.link navigate={~p"/syms"} class="link link-hover">syms</.link> | |
| 57 | + <span :if={@sym} class="opacity-50">/</span> | |
| 58 | + <span :if={@sym} class="text-2xl leading-none">{@sym}</span> | |
| 59 | + </h1> | |
| 60 | + | |
| 61 | + <p :if={@symbols == []} class="text-sm opacity-60"> | |
| 62 | + This instance has no symbols configured, so syms are disabled. | |
| 63 | + </p> | |
| 64 | + | |
| 65 | + <div :if={@symbols != []} class="flex flex-wrap items-center gap-2"> | |
| 66 | + <.link | |
| 67 | + :for={s <- @symbols} | |
| 68 | + navigate={~p"/syms/#{s}"} | |
| 69 | + class={["btn btn-xs gap-1", s == @sym && "btn-primary"]} | |
| 70 | + > | |
| 71 | + <span class="text-base leading-none">{s}</span> | |
| 72 | + <span class="opacity-70">{Map.get(@global_counts, s, 0)}</span> | |
| 73 | + </.link> | |
| 74 | + </div> | |
| 75 | + | |
| 76 | + <p :if={@symbols != [] and @entries == []} class="text-sm opacity-60"> | |
| 77 | + Nothing marked yet. | |
| 78 | + </p> | |
| 79 | + | |
| 80 | + <ol :if={@entries != []} class="divide-y divide-base-300"> | |
| 81 | + <li :for={{e, i} <- Enum.with_index(@entries, 1)} class="py-3 flex items-baseline gap-3"> | |
| 82 | + <span class="text-xs opacity-40 w-8 shrink-0 text-right">{i}</span> | |
| 83 | + | |
| 84 | + <div class="min-w-0 flex-1"> | |
| 85 | + <.link | |
| 86 | + navigate={~p"/r/#{Storage.repo_handle(e.repository)}/#{e.repository.name}"} | |
| 87 | + class="font-medium link link-hover" | |
| 88 | + > | |
| 89 | + {Storage.repo_handle(e.repository)}/{e.repository.name} | |
| 90 | + </.link> | |
| 91 | + <p :if={e.repository.description} class="text-xs opacity-60 truncate"> | |
| 92 | + {e.repository.description} | |
| 93 | + </p> | |
| 94 | + </div> | |
| 95 | + | |
| 96 | + <span class="flex flex-wrap items-center gap-2 shrink-0 text-xs"> | |
| 97 | + <span :for={s <- @symbols} :if={Map.get(e.counts, s, 0) > 0} class="opacity-70"> | |
| 98 | + {s} {Map.get(e.counts, s, 0)} | |
| 99 | + </span> | |
| 100 | + </span> | |
| 101 | + </li> | |
| 102 | + </ol> | |
| 103 | + | |
| 104 | + <p :if={length(@entries) >= @limit} class="text-xs opacity-50"> | |
| 105 | + Showing the top {@limit}. | |
| 106 | + </p> | |
| 107 | + </div> | |
| 108 | + </Layouts.app> | |
| 109 | + """ | |
| 110 | + end | |
| 111 | +end |
modified
lib/git_gud_web/live/user_live/profile.ex
+5
−2
@@ -25,6 +25,7 @@ defmodule GitGudWeb.UserLive.Profile do
| 25 | 25 | |> assign(:pins, pins) |
| 26 | 26 | |> assign(:readme, readme) |
| 27 | 27 | |> assign(:is_self?, viewer && viewer.id == user.id) |
| 28 | + |> assign(:syms, GitGud.Syms.symbols()) | |
| 28 | 29 | |> assign(:syms_enabled?, GitGud.Syms.enabled?()) |
| 29 | 30 | |> assign(:display_name, GitGud.Accounts.User.display(user)) |
| 30 | 31 | |> assign(:page_title, "@#{user.handle}")} |
@@ -56,12 +57,14 @@ defmodule GitGudWeb.UserLive.Profile do
| 56 | 57 | <p class="text-sm font-mono opacity-60">@{@user.handle}</p> |
| 57 | 58 | </div> |
| 58 | 59 | |
| 60 | + <%!-- The instance's own symbol set, as the affordance. --%> | |
| 59 | 61 | <.link |
| 60 | 62 | :if={@syms_enabled?} |
| 61 | 63 | navigate={~p"/u/#{@user.handle}/syms"} |
| 62 | − class="text-sm link link-hover opacity-70" | |
| 64 | + class="badge badge-xs gap-1 py-2 hover:opacity-100 opacity-70" | |
| 65 | + title="syms" | |
| 63 | 66 | > |
| 64 | − syms | |
| 67 | + <span :for={s <- @syms} class="leading-none">{s}</span> | |
| 65 | 68 | </.link> |
| 66 | 69 | |
| 67 | 70 | <.link |
modified
lib/git_gud_web/router.ex
+2
−0
@@ -200,6 +200,8 @@ defmodule GitGudWeb.Router do
| 200 | 200 | # LiveView itself. |
| 201 | 201 | live "/repositories", RepoLive.Index, :index |
| 202 | 202 | live "/u/:handle", UserLive.Profile, :show |
| 203 | + live "/syms", SymsLive.Top, :index | |
| 204 | + live "/syms/:sym", SymsLive.Top, :show | |
| 203 | 205 | live "/u/:handle/syms", SymsLive.Index, :index |
| 204 | 206 | live "/u/:handle/syms/:sym", SymsLive.Show, :show |
| 205 | 207 | live "/orgs/:handle", OrgLive.Show, :show |
modified
test/git_gud_web/live/syms_live/syms_live_test.exs
+23
−0
@@ -76,6 +76,29 @@ defmodule GitGudWeb.SymsLiveTest do
| 76 | 76 | end |
| 77 | 77 | end |
| 78 | 78 | |
| 79 | + describe "the profile badge" do | |
| 80 | + test "shows the instance symbols and links to the user's syms", %{ | |
| 81 | + conn: conn, | |
| 82 | + user: user | |
| 83 | + } do | |
| 84 | + {:ok, lv, _html} = live(conn, "/u/#{user.handle}") | |
| 85 | + | |
| 86 | + badge = lv |> element(~s{a[href="/u/#{user.handle}/syms"]}) | |
| 87 | + assert has_element?(badge) | |
| 88 | + | |
| 89 | + rendered = render(badge) | |
| 90 | + for sym <- ["⭐", "🚀", "❤️"], do: assert(rendered =~ sym) | |
| 91 | + assert rendered =~ "badge-xs" | |
| 92 | + end | |
| 93 | + | |
| 94 | + test "no symbols configured means no badge", %{conn: conn, user: user} do | |
| 95 | + Application.put_env(:git_gud, :syms, []) | |
| 96 | + | |
| 97 | + {:ok, lv, _html} = live(conn, "/u/#{user.handle}") | |
| 98 | + refute lv |> element(~s{a[href="/u/#{user.handle}/syms"]}) |> has_element?() | |
| 99 | + end | |
| 100 | + end | |
| 101 | + | |
| 79 | 102 | describe "user syms pages" do |
| 80 | 103 | test "the index lists every symbol with counts", %{conn: conn, user: user, repo: repo} do |
| 81 | 104 | {:ok, :marked} = Syms.toggle(user, repo, "🚀") |
added
test/git_gud_web/live/syms_live/top_test.exs
+124
−0
@@ -0,0 +1,124 @@
| 1 | +defmodule GitGudWeb.SymsLive.TopTest do | |
| 2 | + use GitGudWeb.ConnCase, async: false | |
| 3 | + | |
| 4 | + import Phoenix.LiveViewTest | |
| 5 | + import GitGud.AccountsFixtures | |
| 6 | + import GitGud.ForgeFixtures | |
| 7 | + | |
| 8 | + alias GitGud.Syms | |
| 9 | + | |
| 10 | + setup do | |
| 11 | + on_exit(fn -> | |
| 12 | + Application.delete_env(:git_gud, :syms) | |
| 13 | + Application.delete_env(:git_gud, :syms_top_limit) | |
| 14 | + end) | |
| 15 | + | |
| 16 | + Application.put_env(:git_gud, :syms, ["⭐", "🚀"]) | |
| 17 | + :ok | |
| 18 | + end | |
| 19 | + | |
| 20 | + defp mark(repo, symbol, n) do | |
| 21 | + for _ <- 1..n do | |
| 22 | + {:ok, :marked} = Syms.toggle(user_fixture(), repo, symbol) | |
| 23 | + end | |
| 24 | + end | |
| 25 | + | |
| 26 | + test "ranks the most-marked repositories" do | |
| 27 | + {_u, popular} = repository_fixture(%{visibility: "public", name: "popular"}) | |
| 28 | + {_u, quiet} = repository_fixture(%{visibility: "public", name: "quiet"}) | |
| 29 | + | |
| 30 | + mark(popular, "⭐", 3) | |
| 31 | + mark(quiet, "⭐", 1) | |
| 32 | + | |
| 33 | + {:ok, _lv, html} = live(build_conn(), ~p"/syms") | |
| 34 | + | |
| 35 | + assert html =~ "popular" | |
| 36 | + assert html =~ "quiet" | |
| 37 | + # Most-marked first. | |
| 38 | + assert :binary.match(html, "popular") < :binary.match(html, "quiet") | |
| 39 | + end | |
| 40 | + | |
| 41 | + test "an anonymous visitor sees only public repos" do | |
| 42 | + {owner, private} = repository_fixture(%{visibility: "private", name: "hushhush"}) | |
| 43 | + mark(private, "⭐", 2) | |
| 44 | + | |
| 45 | + {:ok, _lv, html} = live(build_conn(), ~p"/syms") | |
| 46 | + refute html =~ "hushhush" | |
| 47 | + | |
| 48 | + # …and the owner does see it. | |
| 49 | + conn = log_in_user(build_conn(), owner) | |
| 50 | + {:ok, _lv, html} = live(conn, ~p"/syms") | |
| 51 | + assert html =~ "hushhush" | |
| 52 | + end | |
| 53 | + | |
| 54 | + test "internal repos appear only once logged in" do | |
| 55 | + {_u, internal} = repository_fixture(%{visibility: "internal", name: "staffonly"}) | |
| 56 | + mark(internal, "🚀", 1) | |
| 57 | + | |
| 58 | + {:ok, _lv, html} = live(build_conn(), ~p"/syms") | |
| 59 | + refute html =~ "staffonly" | |
| 60 | + | |
| 61 | + conn = log_in_user(build_conn(), user_fixture()) | |
| 62 | + {:ok, _lv, html} = live(conn, ~p"/syms") | |
| 63 | + assert html =~ "staffonly" | |
| 64 | + end | |
| 65 | + | |
| 66 | + test "hidden repos don't consume slots in the top N" do | |
| 67 | + Application.put_env(:git_gud, :syms_top_limit, 2) | |
| 68 | + | |
| 69 | + {_u, secret} = repository_fixture(%{visibility: "private", name: "secretive"}) | |
| 70 | + {_u, a} = repository_fixture(%{visibility: "public", name: "alpharepo"}) | |
| 71 | + {_u, b} = repository_fixture(%{visibility: "public", name: "betarepo"}) | |
| 72 | + | |
| 73 | + # The private repo outranks both, so a naive filter-after-limit | |
| 74 | + # would leave an anonymous visitor with only one row. | |
| 75 | + mark(secret, "⭐", 5) | |
| 76 | + mark(a, "⭐", 3) | |
| 77 | + mark(b, "⭐", 2) | |
| 78 | + | |
| 79 | + {:ok, _lv, html} = live(build_conn(), ~p"/syms") | |
| 80 | + | |
| 81 | + refute html =~ "secretive" | |
| 82 | + assert html =~ "alpharepo" | |
| 83 | + assert html =~ "betarepo" | |
| 84 | + end | |
| 85 | + | |
| 86 | + test "the per-symbol page ranks only that symbol" do | |
| 87 | + {_u, starred} = repository_fixture(%{visibility: "public", name: "starrepo"}) | |
| 88 | + {_u, rocketed} = repository_fixture(%{visibility: "public", name: "rocketrepo"}) | |
| 89 | + | |
| 90 | + mark(starred, "⭐", 2) | |
| 91 | + mark(rocketed, "🚀", 2) | |
| 92 | + | |
| 93 | + {:ok, _lv, html} = live(build_conn(), ~p"/syms/#{"⭐"}") | |
| 94 | + | |
| 95 | + assert html =~ "starrepo" | |
| 96 | + refute html =~ "rocketrepo" | |
| 97 | + end | |
| 98 | + | |
| 99 | + test "an unconfigured symbol redirects to the index" do | |
| 100 | + assert {:error, {:live_redirect, %{to: "/syms"}}} = live(build_conn(), ~p"/syms/#{"🍕"}") | |
| 101 | + end | |
| 102 | + | |
| 103 | + test "no configured symbols disables the page" do | |
| 104 | + Application.put_env(:git_gud, :syms, []) | |
| 105 | + | |
| 106 | + {:ok, _lv, html} = live(build_conn(), ~p"/syms") | |
| 107 | + assert html =~ "syms are disabled" | |
| 108 | + end | |
| 109 | + | |
| 110 | + test "the limit comes from config" do | |
| 111 | + Application.put_env(:git_gud, :syms_top_limit, 1) | |
| 112 | + | |
| 113 | + {_u, a} = repository_fixture(%{visibility: "public", name: "firstrepo"}) | |
| 114 | + {_u, b} = repository_fixture(%{visibility: "public", name: "secondrepo"}) | |
| 115 | + mark(a, "⭐", 2) | |
| 116 | + mark(b, "⭐", 1) | |
| 117 | + | |
| 118 | + {:ok, _lv, html} = live(build_conn(), ~p"/syms") | |
| 119 | + | |
| 120 | + assert html =~ "firstrepo" | |
| 121 | + refute html =~ "secondrepo" | |
| 122 | + assert html =~ "Showing the top 1" | |
| 123 | + end | |
| 124 | +end |
Parents: 06a9b53