neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
Derive a language tag from a repository's primary language
71e7254 · Gabriel Morell · 2026-09-11 02:02
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/repositories.ex
+22
−0
@@ -1688,6 +1688,11 @@ defmodule GitGud.Repositories do
| 1688 | 1688 | conflict_target: [:repository_id, :sha] |
| 1689 | 1689 | ) |
| 1690 | 1690 | |
| 1691 | + # Keep the repo's derived language tag pointing at whatever now | |
| 1692 | + # leads. Best-effort: a tagging failure shouldn't lose the stats | |
| 1693 | + # that were just computed. | |
| 1694 | + _ = sync_language_tag(repo, items) | |
| 1695 | + | |
| 1691 | 1696 | {:ok, |
| 1692 | 1697 | %{ |
| 1693 | 1698 | languages: items |> Enum.map(&decode_language_item/1), |
@@ -1733,6 +1738,23 @@ defmodule GitGud.Repositories do
| 1733 | 1738 | end |
| 1734 | 1739 | end |
| 1735 | 1740 | |
| 1741 | + # Only the leading language becomes a tag. Tagging every language a | |
| 1742 | + # repo contains would bury the one it's actually written in. | |
| 1743 | + defp sync_language_tag(repo, items) do | |
| 1744 | + top = | |
| 1745 | + case items do | |
| 1746 | + [%{"language" => lang} | _] when is_binary(lang) -> lang | |
| 1747 | + _ -> nil | |
| 1748 | + end | |
| 1749 | + | |
| 1750 | + GitGud.Tags.set_language_tag(repo, top) | |
| 1751 | + rescue | |
| 1752 | + error -> | |
| 1753 | + require Logger | |
| 1754 | + Logger.warning("could not sync language tag for repo #{repo.id}: #{inspect(error)}") | |
| 1755 | + :ok | |
| 1756 | + end | |
| 1757 | + | |
| 1736 | 1758 | defp decode_language_stats(%GitCommitLanguageStats{} = row) do |
| 1737 | 1759 | items = |
| 1738 | 1760 | case row.languages do |
modified
lib/git_gud/tags.ex
+61
−5
@@ -5,6 +5,11 @@ defmodule GitGud.Tags do
| 5 | 5 | Up to seven per repo, normalised to lowercase so a tag means the same |
| 6 | 6 | thing however it was typed. Searching is exact on the normalised |
| 7 | 7 | name; these are labels to browse by, not free text. |
| 8 | + | |
| 9 | + A repo also carries one derived tag for its primary language, kept up | |
| 10 | + to date by the language-stats worker. Derived and typed tags are held | |
| 11 | + apart by `source` so neither can clobber the other, and the derived | |
| 12 | + one doesn't eat into the seven someone gets to choose. | |
| 8 | 13 | """ |
| 9 | 14 | |
| 10 | 15 | import Ecto.Query, warn: false |
@@ -39,15 +44,25 @@ defmodule GitGud.Tags do
| 39 | 44 | |
| 40 | 45 | def normalize(_), do: nil |
| 41 | 46 | |
| 42 | − @doc "Tags on a repository, alphabetical." | |
| 47 | + @doc "Tags on a repository, alphabetical. Both kinds." | |
| 43 | 48 | def list_tags(%Repository{id: rid}) do |
| 44 | 49 | from(t in RepositoryTag, where: t.repository_id == ^rid, order_by: [asc: t.name]) |
| 45 | 50 | |> Repo.all() |
| 46 | 51 | end |
| 47 | 52 | |
| 48 | − @doc "Just the names, for rendering." | |
| 53 | + @doc "Just the names, for rendering. Both kinds." | |
| 49 | 54 | def tag_names(%Repository{} = repo), do: repo |> list_tags() |> Enum.map(& &1.name) |
| 50 | 55 | |
| 56 | + @doc "Only the tags someone typed — what the settings form edits." | |
| 57 | + def manual_tag_names(%Repository{id: rid}) do | |
| 58 | + from(t in RepositoryTag, | |
| 59 | + where: t.repository_id == ^rid and t.source == "manual", | |
| 60 | + order_by: [asc: t.name], | |
| 61 | + select: t.name | |
| 62 | + ) | |
| 63 | + |> Repo.all() | |
| 64 | + end | |
| 65 | + | |
| 51 | 66 | @doc """ |
| 52 | 67 | Tag names for many repositories at once, keyed by repo id. |
| 53 | 68 |
@@ -81,17 +96,23 @@ defmodule GitGud.Tags do
| 81 | 96 | if length(wanted) > @max_tags do |
| 82 | 97 | {:error, :too_many} |
| 83 | 98 | else |
| 84 | − existing = repo |> list_tags() |> Enum.map(& &1.name) | |
| 99 | + existing = manual_tag_names(repo) | |
| 85 | 100 | |
| 86 | 101 | Repo.transaction(fn -> |
| 87 | 102 | for name <- existing -- wanted do |
| 88 | − from(t in RepositoryTag, where: t.repository_id == ^repo.id and t.name == ^name) | |
| 103 | + from(t in RepositoryTag, | |
| 104 | + where: t.repository_id == ^repo.id and t.name == ^name and t.source == "manual" | |
| 105 | + ) | |
| 89 | 106 | |> Repo.delete_all() |
| 90 | 107 | end |
| 91 | 108 | |
| 92 | 109 | for name <- wanted -- existing do |
| 93 | 110 | %RepositoryTag{} |
| 94 | − |> RepositoryTag.changeset(%{repository_id: repo.id, name: name}) | |
| 111 | + |> RepositoryTag.changeset(%{ | |
| 112 | + repository_id: repo.id, | |
| 113 | + name: name, | |
| 114 | + source: "manual" | |
| 115 | + }) | |
| 95 | 116 | |> Repo.insert!() |
| 96 | 117 | end |
| 97 | 118 |
@@ -103,6 +124,41 @@ defmodule GitGud.Tags do
| 103 | 124 | def set_tags(%Repository{} = repo, names) when is_binary(names), |
| 104 | 125 | do: set_tags(repo, String.split(names, ~r/[,\s]+/u)) |
| 105 | 126 | |
| 127 | + @doc """ | |
| 128 | + Point the repo's derived language tag at `language`, replacing | |
| 129 | + whatever was there. | |
| 130 | + | |
| 131 | + Passing nil clears it — a repo whose language can no longer be | |
| 132 | + determined shouldn't keep claiming the old one. | |
| 133 | + | |
| 134 | + Does nothing when the same name is already present as a *manual* | |
| 135 | + tag: someone typing "elixir" themselves shouldn't end up with it | |
| 136 | + twice, and their copy is the one to keep. | |
| 137 | + """ | |
| 138 | + def set_language_tag(%Repository{} = repo, language) do | |
| 139 | + wanted = normalize(language) | |
| 140 | + manual = manual_tag_names(repo) | |
| 141 | + | |
| 142 | + Repo.transaction(fn -> | |
| 143 | + from(t in RepositoryTag, | |
| 144 | + where: t.repository_id == ^repo.id and t.source == "language" | |
| 145 | + ) | |
| 146 | + |> Repo.delete_all() | |
| 147 | + | |
| 148 | + if wanted && wanted not in manual do | |
| 149 | + %RepositoryTag{} | |
| 150 | + |> RepositoryTag.changeset(%{ | |
| 151 | + repository_id: repo.id, | |
| 152 | + name: wanted, | |
| 153 | + source: "language" | |
| 154 | + }) | |
| 155 | + |> Repo.insert!() | |
| 156 | + end | |
| 157 | + | |
| 158 | + wanted | |
| 159 | + end) | |
| 160 | + end | |
| 161 | + | |
| 106 | 162 | @doc """ |
| 107 | 163 | Every tag in use, with how many repos carry it. |
| 108 | 164 |
modified
lib/git_gud/tags/repository_tag.ex
+9
−1
@@ -4,8 +4,13 @@ defmodule GitGud.Tags.RepositoryTag do
| 4 | 4 | |
| 5 | 5 | alias GitGud.Repositories.Repository |
| 6 | 6 | |
| 7 | + @sources ~w(manual language) | |
| 8 | + | |
| 7 | 9 | schema "repository_tags" do |
| 8 | 10 | field :name, :string |
| 11 | + # "manual" — someone typed it; "language" — derived from the repo's | |
| 12 | + # primary language by the stats worker. | |
| 13 | + field :source, :string, default: "manual" | |
| 9 | 14 | |
| 10 | 15 | belongs_to :repository, Repository |
| 11 | 16 |
@@ -14,12 +19,15 @@ defmodule GitGud.Tags.RepositoryTag do
| 14 | 19 | |
| 15 | 20 | def changeset(tag, attrs) do |
| 16 | 21 | tag |
| 17 | − |> cast(attrs, [:repository_id, :name]) | |
| 22 | + |> cast(attrs, [:repository_id, :name, :source]) | |
| 18 | 23 | |> validate_required([:repository_id, :name]) |
| 24 | + |> validate_inclusion(:source, @sources) | |
| 19 | 25 | |> validate_length(:name, min: 1, max: 40) |
| 20 | 26 | # Names arrive already normalised by `Tags.normalize/1`; this is the |
| 21 | 27 | # backstop for anything inserted directly. |
| 22 | 28 | |> validate_format(:name, ~r/\A[a-z0-9._+-]+\z/) |
| 23 | 29 | |> unique_constraint([:repository_id, :name]) |
| 24 | 30 | end |
| 31 | + | |
| 32 | + def sources, do: @sources | |
| 25 | 33 | end |
modified
lib/git_gud_web/live/repo_live/settings.ex
+4
−3
@@ -39,7 +39,7 @@ defmodule GitGudWeb.RepoLive.Settings do
| 39 | 39 | socket |
| 40 | 40 | |> assign(:repo, repo) |
| 41 | 41 | |> assign(:handle, Storage.repo_handle(repo)) |
| 42 | − |> assign(:tags, GitGud.Tags.tag_names(repo)) | |
| 42 | + |> assign(:tags, GitGud.Tags.manual_tag_names(repo)) | |
| 43 | 43 | |> GitGudWeb.RepoLive.Header.assign_chrome(repo) |
| 44 | 44 | |> assign(:page_title, "Settings — #{owner}/#{name}") |
| 45 | 45 | |> assign(:delete_confirm, "") |
@@ -93,7 +93,7 @@ defmodule GitGudWeb.RepoLive.Settings do
| 93 | 93 | socket |
| 94 | 94 | |> assign(:repo, GitGud.Repo.preload(repo, [:owner, :organization])) |
| 95 | 95 | |> assign(:form, to_form(blank_changeset(repo))) |
| 96 | − |> assign(:tags, GitGud.Tags.tag_names(repo)) | |
| 96 | + |> assign(:tags, GitGud.Tags.manual_tag_names(repo)) | |
| 97 | 97 | |> flash_for_save(tag_result)} |
| 98 | 98 | |
| 99 | 99 | {:error, cs} -> |
@@ -222,7 +222,8 @@ defmodule GitGudWeb.RepoLive.Settings do
| 222 | 222 | </label> |
| 223 | 223 | <p class="text-xs opacity-60 -mt-2"> |
| 224 | 224 | Up to {GitGud.Tags.max_tags()}, comma or space separated. Lowercased, so |
| 225 | − "Elixir" and "elixir" are the same tag. Browse them at <.link | |
| 225 | + "Elixir" and "elixir" are the same tag. The repo's primary language is | |
| 226 | + tagged automatically and isn't listed here. Browse them at <.link | |
| 226 | 227 | navigate={~p"/tags"} |
| 227 | 228 | class="link link-hover" |
| 228 | 229 | >/tags</.link>. |
added
lib/mix/tasks/gitgud.backfill_language_tags.ex
+61
−0
@@ -0,0 +1,61 @@
| 1 | +defmodule Mix.Tasks.Gitgud.BackfillLanguageTags do | |
| 2 | + @moduledoc """ | |
| 3 | + Apply each repository's derived language tag from the language stats | |
| 4 | + already computed for it. | |
| 5 | + | |
| 6 | + New stats tag the repo as they're written, so this is only needed | |
| 7 | + once, for repos whose stats predate the tag. | |
| 8 | + | |
| 9 | + mix gitgud.backfill_language_tags | |
| 10 | + | |
| 11 | + Starts the Repo alone rather than the whole application, so it can be | |
| 12 | + run against a live instance — booting the app would fight the running | |
| 13 | + server for its fixed SSH and metrics ports. | |
| 14 | + | |
| 15 | + Repos with no computed stats are left alone rather than having their | |
| 16 | + tag cleared — "we haven't looked yet" and "there's no language" are | |
| 17 | + different, and only the second should remove a tag. | |
| 18 | + | |
| 19 | + Safe to re-run: it sets each tag to what the stats currently say. | |
| 20 | + """ | |
| 21 | + @shortdoc "Derive language tags from existing language stats" | |
| 22 | + | |
| 23 | + use Mix.Task | |
| 24 | + | |
| 25 | + alias GitGud.Repositories | |
| 26 | + alias GitGud.Repositories.Repository | |
| 27 | + alias GitGud.Tags | |
| 28 | + | |
| 29 | + @impl true | |
| 30 | + def run(_args) do | |
| 31 | + Mix.Task.run("app.config") | |
| 32 | + {:ok, _} = Application.ensure_all_started(:ecto_sql) | |
| 33 | + {:ok, _} = Application.ensure_all_started(:postgrex) | |
| 34 | + | |
| 35 | + started? = | |
| 36 | + case GitGud.Repo.start_link() do | |
| 37 | + {:ok, _pid} -> true | |
| 38 | + {:error, {:already_started, _pid}} -> false | |
| 39 | + end | |
| 40 | + | |
| 41 | + repos = GitGud.Repo.all(Repository) | |
| 42 | + languages = Repositories.top_languages_for(Enum.map(repos, & &1.id)) | |
| 43 | + | |
| 44 | + {tagged, skipped} = | |
| 45 | + Enum.reduce(repos, {0, 0}, fn repo, {tagged, skipped} -> | |
| 46 | + case Map.get(languages, repo.id) do | |
| 47 | + nil -> | |
| 48 | + {tagged, skipped + 1} | |
| 49 | + | |
| 50 | + language -> | |
| 51 | + {:ok, name} = Tags.set_language_tag(repo, language) | |
| 52 | + Mix.shell().info("#{repo.name}: #{name || "(cleared)"}") | |
| 53 | + {tagged + 1, skipped} | |
| 54 | + end | |
| 55 | + end) | |
| 56 | + | |
| 57 | + Mix.shell().info("\n#{tagged} tagged, #{skipped} with no computed stats yet.") | |
| 58 | + | |
| 59 | + if started?, do: GitGud.Repo.stop() | |
| 60 | + end | |
| 61 | +end |
added
priv/repo/migrations/20260910100000_add_tag_source.exs
+19
−0
@@ -0,0 +1,19 @@
| 1 | +defmodule GitGud.Repo.Migrations.AddTagSource do | |
| 2 | + use Ecto.Migration | |
| 3 | + | |
| 4 | + # Where a tag came from: someone typed it, or it was derived from the | |
| 5 | + # repo's primary language. | |
| 6 | + # | |
| 7 | + # Kept apart so the two can't clobber each other — recomputing | |
| 8 | + # language stats must not wipe tags a person chose, and editing tags | |
| 9 | + # in settings must not fight the worker over the language one. | |
| 10 | + def change do | |
| 11 | + alter table(:repository_tags) do | |
| 12 | + add :source, :string, null: false, default: "manual" | |
| 13 | + end | |
| 14 | + | |
| 15 | + # One derived tag per repo at most; "what did we auto-apply here?" | |
| 16 | + # is looked up every time stats are recomputed. | |
| 17 | + create index(:repository_tags, [:repository_id, :source]) | |
| 18 | + end | |
| 19 | +end |
modified
test/git_gud_web/live/tags_test.exs
+80
−0
@@ -244,5 +244,85 @@ defmodule GitGudWeb.TagsTest do
| 244 | 244 | end |
| 245 | 245 | end |
| 246 | 246 | |
| 247 | + describe "the derived language tag" do | |
| 248 | + test "is applied and replaces itself as the language changes" do | |
| 249 | + {_user, repo} = repository_fixture() | |
| 250 | + | |
| 251 | + {:ok, _} = Tags.set_language_tag(repo, "Elixir") | |
| 252 | + assert Tags.tag_names(repo) == ["elixir"] | |
| 253 | + | |
| 254 | + {:ok, _} = Tags.set_language_tag(repo, "Rust") | |
| 255 | + assert Tags.tag_names(repo) == ["rust"] | |
| 256 | + end | |
| 257 | + | |
| 258 | + test "nil clears it rather than leaving a stale claim" do | |
| 259 | + {_user, repo} = repository_fixture() | |
| 260 | + {:ok, _} = Tags.set_language_tag(repo, "Elixir") | |
| 261 | + | |
| 262 | + {:ok, _} = Tags.set_language_tag(repo, nil) | |
| 263 | + | |
| 264 | + assert Tags.tag_names(repo) == [] | |
| 265 | + end | |
| 266 | + | |
| 267 | + test "doesn't touch tags someone typed" do | |
| 268 | + {_user, repo} = repository_fixture() | |
| 269 | + {:ok, _} = Tags.set_tags(repo, ["git", "self-hosted"]) | |
| 270 | + | |
| 271 | + {:ok, _} = Tags.set_language_tag(repo, "Elixir") | |
| 272 | + | |
| 273 | + assert Tags.tag_names(repo) == ["elixir", "git", "self-hosted"] | |
| 274 | + assert Tags.manual_tag_names(repo) == ["git", "self-hosted"] | |
| 275 | + end | |
| 276 | + | |
| 277 | + test "editing manual tags doesn't remove it" do | |
| 278 | + {_user, repo} = repository_fixture() | |
| 279 | + {:ok, _} = Tags.set_language_tag(repo, "Elixir") | |
| 280 | + | |
| 281 | + {:ok, _} = Tags.set_tags(repo, ["git"]) | |
| 282 | + | |
| 283 | + assert Tags.tag_names(repo) == ["elixir", "git"] | |
| 284 | + end | |
| 285 | + | |
| 286 | + test "defers to a manual tag of the same name rather than duplicating" do | |
| 287 | + {_user, repo} = repository_fixture() | |
| 288 | + {:ok, _} = Tags.set_tags(repo, ["elixir"]) | |
| 289 | + | |
| 290 | + {:ok, _} = Tags.set_language_tag(repo, "Elixir") | |
| 291 | + | |
| 292 | + assert Tags.tag_names(repo) == ["elixir"] | |
| 293 | + assert Tags.manual_tag_names(repo) == ["elixir"] | |
| 294 | + end | |
| 295 | + | |
| 296 | + test "doesn't eat into the manual allowance" do | |
| 297 | + {_user, repo} = repository_fixture() | |
| 298 | + {:ok, _} = Tags.set_language_tag(repo, "Elixir") | |
| 299 | + | |
| 300 | + full = for n <- 1..Tags.max_tags(), do: "tag#{n}" | |
| 301 | + assert {:ok, _} = Tags.set_tags(repo, full) | |
| 302 | + | |
| 303 | + assert length(Tags.tag_names(repo)) == Tags.max_tags() + 1 | |
| 304 | + end | |
| 305 | + | |
| 306 | + test "it is browsable like any other" do | |
| 307 | + {_user, repo} = repository_fixture(%{visibility: "public"}) | |
| 308 | + {:ok, _} = Tags.set_language_tag(repo, "Elixir") | |
| 309 | + | |
| 310 | + assert [found] = Tags.list_repositories("elixir", nil) | |
| 311 | + assert found.id == repo.id | |
| 312 | + end | |
| 313 | + | |
| 314 | + test "the settings form lists only manual tags", %{conn: conn} do | |
| 315 | + {owner, repo} = repository_fixture() | |
| 316 | + {:ok, _} = Tags.set_tags(repo, ["hand-typed"]) | |
| 317 | + {:ok, _} = Tags.set_language_tag(repo, "Elixir") | |
| 318 | + | |
| 319 | + {:ok, _lv, html} = live(log_in_user(conn, owner), settings_path(repo)) | |
| 320 | + | |
| 321 | + assert html =~ ~s(value="hand-typed") | |
| 322 | + refute html =~ ~s(value="elixir, hand-typed") | |
| 323 | + refute html =~ ~s(value="hand-typed, elixir") | |
| 324 | + end | |
| 325 | + end | |
| 326 | + | |
| 247 | 327 | defp mine_owner(repo), do: GitGud.Repo.get!(GitGud.Accounts.User, repo.owner_id) |
| 248 | 328 | end |
Parents: 83a7db3