Add syms: stars, but with a configurable set of symbols

06a9b53 · Gabriel Morell · 2026-09-09 21:27

12 files +656 -11
Message
{commit_body(@commit)}

Files changed

modified config/config.exs
+7 −3
@@ -26,7 +26,12 @@ config :git_gud,
26 26 # Registration mode. One of: :open, :invite_only, :closed.
27 27 # Defaults to :invite_only — safer for fresh deploys. Override in
28 28 # runtime.exs / per-env config (use :open for unrestricted signups).
29 registration_mode: :invite_only
29 + registration_mode: :invite_only,
30 + # Symbols users can mark repositories with (the "syms" feature —
31 + # stars, but a set). Redefine per instance; an empty list disables the
32 + # feature entirely. Removing a symbol only hides it: existing marks
33 + # stay in the database and come back if it is re-added.
34 + syms: ["⭐", "🚀", "💔", "⁉️", "❤️"]
30 35
31 36 # Oban for background jobs (cache backfill, webhook delivery, CI dispatch,
32 37 # federation retention).
@@ -61,8 +66,7 @@ config :git_gud, Oban,
61 66
62 67 # Stale-job reaper threshold. Lower = faster recovery, higher = more
63 68 # tolerant of slow runners. Tune per deployment.
64 config :git_gud, GitGud.Workflows.Workers.StaleJobReaper,
65 stale_after_minutes: 5
69 +config :git_gud, GitGud.Workflows.Workers.StaleJobReaper, stale_after_minutes: 5
66 70
67 71 # Federation retention. nil = keep forever. Tune per deployment.
68 72 config :git_gud, GitGud.Federation.Workers.Retention,
added lib/git_gud/syms.ex
+127 −0
@@ -0,0 +1,127 @@
1 +defmodule GitGud.Syms do
2 + @moduledoc """
3 + "Syms" — stars, but with a set of symbols.
4 +
5 + A user marks a repository with any of the instance's configured
6 + symbols, several at once if they like. The set lives in config:
7 +
8 + config :git_gud, syms: ["⭐", "🚀", "💔", "⁉️", "❤️"]
9 +
10 + An empty list disables the feature: nothing renders, and marking is
11 + refused. Dropping a symbol from the list only hides itthe rows stay
12 + and reappear if it is added back, so an edit never destroys marks.
13 + Everything read back through this module is filtered to the currently
14 + configured set, so retired symbols stay invisible until then.
15 + """
16 +
17 + import Ecto.Query, warn: false
18 +
19 + alias GitGud.Accounts.User
20 + alias GitGud.Repo
21 + alias GitGud.Repositories.Repository
22 + alias GitGud.Syms.RepositorySym
23 +
24 + @default_symbols ["⭐", "🚀", "💔", "⁉️", "❤️"]
25 +
26 + @doc """
27 + The instance's symbol set, in configured order. Non-list or
28 + non-binary entries are ignored rather than crashing a page render.
29 + """
30 + def symbols do
31 + case Application.get_env(:git_gud, :syms, @default_symbols) do
32 + list when is_list(list) -> list |> Enum.filter(&is_binary/1) |> Enum.uniq()
33 + _ -> []
34 + end
35 + end
36 +
37 + @doc "False when the instance has configured no symbols."
38 + def enabled?, do: symbols() != []
39 +
40 + @doc "Is this one of the instance's current symbols?"
41 + def known?(symbol), do: symbol in symbols()
42 +
43 + @doc """
44 + Add or remove one mark. Returns `{:ok, :marked | :unmarked}`, or
45 + `{:error, :unknown_symbol}` for anything not currently configured
46 + which also covers the whole feature being disabled.
47 + """
48 + def toggle(%User{id: uid}, %Repository{id: rid}, symbol) do
49 + if known?(symbol) do
50 + case Repo.get_by(RepositorySym, user_id: uid, repository_id: rid, symbol: symbol) do
51 + nil ->
52 + %RepositorySym{user_id: uid, repository_id: rid, symbol: symbol}
53 + |> Ecto.Changeset.change()
54 + |> Repo.insert()
55 + |> case do
56 + {:ok, _} -> {:ok, :marked}
57 + err -> err
58 + end
59 +
60 + row ->
61 + Repo.delete(row)
62 + {:ok, :unmarked}
63 + end
64 + else
65 + {:error, :unknown_symbol}
66 + end
67 + end
68 +
69 + @doc "The symbols this user has put on this repo, configured ones only."
70 + def marks(%User{id: uid}, %Repository{id: rid}) do
71 + current = symbols()
72 +
73 + from(s in RepositorySym,
74 + where: s.user_id == ^uid and s.repository_id == ^rid,
75 + select: s.symbol
76 + )
77 + |> Repo.all()
78 + |> Enum.filter(&(&1 in current))
79 + end
80 +
81 + def marks(nil, _repo), do: []
82 +
83 + @doc """
84 + Counts per symbol for a repository, as `%{symbol => count}`. Only
85 + configured symbols appear; symbols with no marks are absent.
86 + """
87 + def counts(%Repository{id: rid}), do: counts_by(:repository_id, rid)
88 +
89 + @doc "Counts per symbol across everything a user has marked."
90 + def counts_for_user(%User{id: uid}), do: counts_by(:user_id, uid)
91 +
92 + defp counts_by(field, id) do
93 + current = symbols()
94 +
95 + from(s in RepositorySym,
96 + where: field(s, ^field) == ^id,
97 + group_by: s.symbol,
98 + select: {s.symbol, count(s.id)}
99 + )
100 + |> Repo.all()
101 + |> Enum.filter(fn {sym, _} -> sym in current end)
102 + |> Map.new()
103 + end
104 +
105 + @doc """
106 + Repositories `user` marked with `symbol`, newest mark first.
107 +
108 + Returns `[]` for a symbol the instance no longer configures. Callers
109 + are responsible for visibility filteringsee
110 + `GitGud.Repositories.can_read?/2` equivalents at the call site.
111 + """
112 + def list_marked(%User{id: uid}, symbol) do
113 + if known?(symbol) do
114 + from(s in RepositorySym,
115 + join: r in Repository,
116 + on: r.id == s.repository_id,
117 + where: s.user_id == ^uid and s.symbol == ^symbol,
118 + order_by: [desc: s.inserted_at],
119 + select: r
120 + )
121 + |> Repo.all()
122 + |> Repo.preload([:owner, :organization])
123 + else
124 + []
125 + end
126 + end
127 +end
added lib/git_gud/syms/repository_sym.ex
+20 −0
@@ -0,0 +1,20 @@
1 +defmodule GitGud.Syms.RepositorySym do
2 + @moduledoc """
3 + One user's mark of one symbol on one repository.
4 +
5 + A user may mark the same repo with several different symbols; the
6 + unique index is on the triple, not on (user, repo).
7 + """
8 + use Ecto.Schema
9 +
10 + alias GitGud.Accounts.User
11 + alias GitGud.Repositories.Repository
12 +
13 + schema "repository_syms" do
14 + field :symbol, :string
15 + belongs_to :user, User
16 + belongs_to :repository, Repository
17 +
18 + timestamps(type: :utc_datetime, updated_at: false)
19 + end
20 +end
modified lib/git_gud_web/live/repo_live/header.ex
+17 −8
@@ -122,14 +122,23 @@ defmodule GitGudWeb.RepoLive.Header do
122 122 <% end %>
123 123 </p>
124 124 </div>
125 <.link
126 :if={@current_scope && @current_scope.user}
127 navigate={~p"/r/#{@handle}/#{@repo.name}/fork"}
128 class="btn btn-sm"
129 id="fork-button"
130 >
131 <.icon name="hero-arrow-trending-up" class="size-4" /> Fork
132 </.link>
125 + <div class="flex items-center gap-2">
126 + <.live_component
127 + module={GitGudWeb.SymsComponent}
128 + id={"syms-#{@repo.id}"}
129 + repo={@repo}
130 + current_scope={@current_scope}
131 + />
132 +
133 + <.link
134 + :if={@current_scope && @current_scope.user}
135 + navigate={~p"/r/#{@handle}/#{@repo.name}/fork"}
136 + class="btn btn-sm"
137 + id="fork-button"
138 + >
139 + <.icon name="hero-arrow-trending-up" class="size-4" /> Fork
140 + </.link>
141 + </div>
133 142 </header>
134 143 """
135 144 end
added lib/git_gud_web/live/syms_component.ex
+95 −0
@@ -0,0 +1,95 @@
1 +defmodule GitGudWeb.SymsComponent do
2 + @moduledoc """
3 + The syms dropdown that sits beside the Fork button.
4 +
5 + A `live_component` rather than a function component because it handles
6 + its own clicks: the repo header renders on ~30 different LiveViews and
7 + none of them should need a `toggle_sym` clause.
8 +
9 + Collapsed it shows the instance's first symbol; opening it lists the
10 + rest with counts. Renders nothing at all when no symbols are
11 + configured.
12 + """
13 + use GitGudWeb, :live_component
14 +
15 + alias GitGud.Syms
16 +
17 + @impl true
18 + def update(assigns, socket) do
19 + viewer = assigns[:current_scope] && assigns.current_scope.user
20 +
21 + {:ok,
22 + socket
23 + |> assign(assigns)
24 + |> assign(:viewer, viewer)
25 + |> load(assigns.repo, viewer)}
26 + end
27 +
28 + defp load(socket, repo, viewer) do
29 + socket
30 + |> assign(:symbols, Syms.symbols())
31 + |> assign(:counts, Syms.counts(repo))
32 + |> assign(:mine, Syms.marks(viewer, repo))
33 + end
34 +
35 + @impl true
36 + def handle_event("toggle_sym", %{"sym" => sym}, socket) do
37 + case socket.assigns.viewer do
38 + nil ->
39 + {:noreply, socket}
40 +
41 + user ->
42 + _ = Syms.toggle(user, socket.assigns.repo, sym)
43 + {:noreply, load(socket, socket.assigns.repo, user)}
44 + end
45 + end
46 +
47 + @impl true
48 + def render(assigns) do
49 + ~H"""
50 + <div>
51 + <div :if={@symbols != []} class="dropdown dropdown-end">
52 + <div
53 + tabindex="0"
54 + role="button"
55 + class="btn btn-sm gap-1"
56 + aria-label="Mark this repository"
57 + title="Mark this repository"
58 + >
59 + <span class="text-base leading-none">{hd(@symbols)}</span>
60 + <span :if={total(@counts) > 0} class="opacity-70">{total(@counts)}</span>
61 + <.icon name="hero-chevron-down" class="size-3 opacity-60" />
62 + </div>
63 +
64 + <div
65 + tabindex="0"
66 + class="dropdown-content z-10 mt-1 w-56 p-2 shadow-lg bg-base-200 border border-base-300 rounded-box space-y-1"
67 + >
68 + <button
69 + :for={sym <- @symbols}
70 + type="button"
71 + phx-click="toggle_sym"
72 + phx-value-sym={sym}
73 + phx-target={@myself}
74 + disabled={is_nil(@viewer)}
75 + aria-pressed={to_string(sym in @mine)}
76 + class={[
77 + "btn btn-xs btn-block justify-between",
78 + sym in @mine && "btn-primary"
79 + ]}
80 + >
81 + <span class="text-base leading-none">{sym}</span>
82 + <span class="opacity-70">{Map.get(@counts, sym, 0)}</span>
83 + </button>
84 +
85 + <p :if={is_nil(@viewer)} class="text-xs opacity-60 px-1 pt-1">
86 + Log in to mark this repository.
87 + </p>
88 + </div>
89 + </div>
90 + </div>
91 + """
92 + end
93 +
94 + defp total(counts), do: counts |> Map.values() |> Enum.sum()
95 +end
added lib/git_gud_web/live/syms_live/index.ex
+62 −0

Click to load diff…

added lib/git_gud_web/live/syms_live/show.ex
+80 −0

Click to load diff…

modified lib/git_gud_web/live/user_live/profile.ex
+9 −0

Click to load diff…

modified lib/git_gud_web/router.ex
+2 −0

Click to load diff…

added priv/repo/migrations/20260909030000_create_repository_syms.exs
+20 −0

Click to load diff…

added test/git_gud/syms_test.exs
+94 −0

Click to load diff…

added test/git_gud_web/live/syms_live/syms_live_test.exs
+123 −0

Click to load diff…

Parents: fadd7e0