Add an instance-wide /syms ranking

34f99c6 · Gabriel Morell · 2026-09-09 21:32

7 files +393 -3
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 limitfiltering
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