Derive a language tag from a repository's primary language

71e7254 · Gabriel Morell · 2026-09-11 02:02

7 files +256 -9
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 instancebooting 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