neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
Log in to mark this repository.
Add syms: stars, but with a configurable set of symbols
06a9b53 · Gabriel Morell · 2026-09-09 21:27
Message
{commit_body(@commit)}
Files changed
modified
config/config.exs
+7
−3
| 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 | 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
| 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 it — the 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 filtering — see | |
| 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
| 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 | 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
| 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