Make the language-tag backfill runnable in a release

3fb3f48 · Gabriel Morell · 2026-09-12 03:11

11 files +231 -16
Message
{commit_body(@commit)}

Files changed

modified README.md
+28 −0
@@ -490,6 +490,34 @@ developer's machine happened to have clang installed. `podman build -f
490 490 Dockerfile .` is the only thing that settles it. The compression
491 491 crates are pure Rust, so they cost nothing either way.
492 492
493 +## Release tasks
494 +
495 +A release ships no Mix, so the one-shot maintenance jobs are functions
496 +on `GitGud.Release`, run through `eval` in a deployed pod:
497 +
498 +```sh
499 +kubectl -n git-gud exec deployment/git-gud -- \
500 + bin/git_gud eval 'GitGud.Release.<task>()'
501 +```
502 +
503 +| Task | What it does |
504 +|---|---|
505 +| `migrate()` | Run pending migrations. The one you need on every deploy that ships one |
506 +| `rollback(GitGud.Repo, version)` | Roll a migration back |
507 +| `keygen()` | Mint the RS256 keypair for the OCI token endpoint, printing both PEMs with `===PRIV===` / `===PUB===` delimiters so they can be piped into `kubectl create secret` without touching a laptop |
508 +| `configure_repos()` | Backfill the per-repo git config `actions/checkout` needs (`uploadpack.allow*SHA1InWant`). New repos get it at creation; this fixes older ones |
509 +| `backfill_language_tags()` | Derive each repo's language tag from the stats already computed for it. New stats tag their repo as they're written, so this is for repos whose stats predate the tag |
510 +| `sentry_test()` | Send a synchronous test event to verify the DSN |
511 +
512 +All of them are idempotent and safe against a live instance: each
513 +starts only what it needs rather than booting the application, which
514 +would contend with the running server for its fixed SSH and metrics
515 +ports.
516 +
517 +`mix gitgud.backfill_language_tags` and `mix gitgud.registry.keygen`
518 +are the same jobs for a checkout — they share their implementation
519 +with the release functions rather than duplicating it.
520 +
493 521 ## What's not done
494 522
495 523 See [ROADMAP.md](./ROADMAP.md). Highlights:
modified lib/git_gud/release.ex
+35 −0
@@ -80,6 +80,41 @@ defmodule GitGud.Release do
80 80 :ok
81 81 end
82 82
83 + @doc """
84 + Backfill each repository's derived language tag from the language
85 + stats already computed for it.
86 +
87 + New stats tag their repo as they're written, so this is a one-shot
88 + for repos whose stats predate the tag — and for the instance that
89 + has been running since before the feature existed, which is the only
90 + place it matters.
91 +
92 + Run in a deployed pod (no Mix in a release):
93 +
94 + kubectl -n git-gud exec deployment/git-gud -- \\
95 + bin/git_gud eval 'GitGud.Release.backfill_language_tags()'
96 +
97 + Idempotent, and safe against a live instance: `with_repo/2` starts
98 + the repo for the duration and stops it after, so this never contends
99 + with the running server for a port the way booting the whole
100 + application would.
101 +
102 + Repos with no computed stats are left alone rather than cleared.
103 + """
104 + def backfill_language_tags do
105 + load_app()
106 +
107 + {:ok, {tagged, skipped}, _} =
108 + Ecto.Migrator.with_repo(GitGud.Repo, fn _repo ->
109 + GitGud.Repositories.backfill_language_tags(
110 + on_tag: fn repo, tag -> IO.puts("#{repo.name}: #{tag || "(cleared)"}") end
111 + )
112 + end)
113 +
114 + IO.puts("\n#{tagged} tagged, #{skipped} with no computed stats yet.")
115 + :ok
116 + end
117 +
83 118 @doc """
84 119 Send a test event to Sentry to verify the DSN + integration:
85 120
modified lib/git_gud/repositories.ex
+36 −0
@@ -1738,6 +1738,42 @@ defmodule GitGud.Repositories do
1738 1738 end
1739 1739 end
1740 1740
1741 + @doc """
1742 + Apply every repository's derived language tag from the stats already
1743 + computed for it, and report `{tagged, skipped}`.
1744 +
1745 + New stats tag their repo as they're written, so this is only for
1746 + repos whose stats predate the tag. Safe to re-run: it sets each tag
1747 + to whatever the stats currently say.
1748 +
1749 + Repositories with no computed stats are **skipped, not cleared** —
1750 + "we haven't looked yet" and "there's no language here" are different
1751 + states, and only the second should remove a tag.
1752 +
1753 + Callers differ only in how they report progress, so `:on_tag` is a
1754 + callback rather than each of them re-implementing the walk:
1755 + `Mix.Tasks.Gitgud.BackfillLanguageTags` prints through `Mix.shell/0`,
1756 + `GitGud.Release.backfill_language_tags/0` through `IO.puts/1`.
1757 + """
1758 + def backfill_language_tags(opts \\ []) do
1759 + on_tag = Keyword.get(opts, :on_tag, fn _repo, _tag -> :ok end)
1760 +
1761 + repos = Repo.all(Repository)
1762 + languages = top_languages_for(Enum.map(repos, & &1.id))
1763 +
1764 + Enum.reduce(repos, {0, 0}, fn repo, {tagged, skipped} ->
1765 + case Map.get(languages, repo.id) do
1766 + nil ->
1767 + {tagged, skipped + 1}
1768 +
1769 + language ->
1770 + {:ok, name} = GitGud.Tags.set_language_tag(repo, language)
1771 + on_tag.(repo, name)
1772 + {tagged + 1, skipped}
1773 + end
1774 + end)
1775 + end
1776 +
1741 1777 # Only the leading language becomes a tag. Tagging every language a
1742 1778 # repo contains would bury the one it's actually written in.
1743 1779 defp sync_language_tag(repo, items) do
modified lib/mix/tasks/gitgud.backfill_language_tags.ex
+8 −16
@@ -17,14 +17,17 @@ defmodule Mix.Tasks.Gitgud.BackfillLanguageTags do
17 17 different, and only the second should remove a tag.
18 18
19 19 Safe to re-run: it sets each tag to what the stats currently say.
20 +
21 + The walk itself is `GitGud.Repositories.backfill_language_tags/1`,
22 + shared with `GitGud.Release.backfill_language_tags/0` — a release
23 + ships no Mix, so a deployed instance needs the other entry point,
24 + and two copies of this would drift.
20 25 """
21 26 @shortdoc "Derive language tags from existing language stats"
22 27
23 28 use Mix.Task
24 29
25 30 alias GitGud.Repositories
26 − alias GitGud.Repositories.Repository
27 − alias GitGud.Tags
28 31
29 32 @impl true
30 33 def run(_args) do
@@ -38,21 +41,10 @@ defmodule Mix.Tasks.Gitgud.BackfillLanguageTags do
38 41 {:error, {:already_started, _pid}} -> false
39 42 end
40 43
41 − repos = GitGud.Repo.all(Repository)
42 − languages = Repositories.top_languages_for(Enum.map(repos, & &1.id))
43 −
44 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)
45 + Repositories.backfill_language_tags(
46 + on_tag: fn repo, name -> Mix.shell().info("#{repo.name}: #{name || "(cleared)"}") end
47 + )
56 48
57 49 Mix.shell().info("\n#{tagged} tagged, #{skipped} with no computed stats yet.")
58 50
added test/git_gud/backfill_language_tags_test.exs
+124 −0
@@ -0,0 +1,124 @@
1 +defmodule GitGud.BackfillLanguageTagsTest do
2 + @moduledoc """
3 + `Repositories.backfill_language_tags/1` — the one-shot that derives a
4 + language tag for repositories whose stats predate the tag.
5 +
6 + The behaviour worth pinning is the *skip*: a repo with no computed
7 + stats must keep whatever tag it has rather than have it cleared.
8 + "We haven't looked yet" and "there's no language here" are different
9 + states, and a backfill that confuses them quietly strips tags from
10 + every repo the stats worker hasn't reached.
11 + """
12 + use GitGud.DataCase, async: false
13 +
14 + alias GitGud.Repositories
15 + alias GitGud.Repositories.GitCommitLanguageStats
16 + alias GitGud.Tags
17 +
18 + import GitGud.ForgeFixtures
19 +
20 + # `top_languages_for/1` reads the newest stats row per repo and takes
21 + # the leading language, so a row is all this needs — no git required.
22 + defp stats(repo, language, opts \\ []) do
23 + Repo.insert_all(GitCommitLanguageStats, [
24 + %{
25 + repository_id: repo.id,
26 + sha: :crypto.strong_rand_bytes(20),
27 + languages: %{"items" => [%{"language" => language, "bytes" => 100}]},
28 + total_bytes: 100,
29 + file_count: 1,
30 + computed_at: Keyword.get(opts, :computed_at, DateTime.utc_now(:second))
31 + }
32 + ])
33 + end
34 +
35 + defp tags(repo), do: repo |> Tags.list_tags() |> Enum.map(&{&1.name, &1.source})
36 +
37 + # Registering a user provisions their `<handle>/<handle>` profile repo,
38 + # so the instance always holds more repositories than a test created.
39 + # Assertions are about the repos under test, never a global total.
40 + defp backfill(opts \\ []), do: Repositories.backfill_language_tags(opts)
41 +
42 + describe "backfill_language_tags/1" do
43 + test "tags a repository from its computed stats" do
44 + {_user, repo} = repository_fixture()
45 + stats(repo, "Elixir")
46 +
47 + assert {tagged, _skipped} = backfill()
48 + assert tagged >= 1
49 + assert tags(repo) == [{"elixir", "language"}]
50 + end
51 +
52 + test "leaves a repo with no computed stats alone rather than clearing it" do
53 + {_user, tagged} = repository_fixture()
54 + {_user, untouched} = repository_fixture()
55 +
56 + stats(tagged, "Rust")
57 + # A stale derived tag on a repo the stats worker hasn't reached.
58 + {:ok, _} = Tags.set_language_tag(untouched, "Elixir")
59 +
60 + assert {_tagged, skipped} = backfill()
61 + # The profile repos have no stats either, so several are skipped;
62 + # what matters is that skipping left the tag in place.
63 + assert skipped >= 1
64 +
65 + assert tags(tagged) == [{"rust", "language"}]
66 + assert tags(untouched) == [{"elixir", "language"}]
67 + end
68 +
69 + test "is idempotent — re-running sets the same tag" do
70 + {_user, repo} = repository_fixture()
71 + stats(repo, "Elixir")
72 +
73 + backfill()
74 + backfill()
75 +
76 + assert tags(repo) == [{"elixir", "language"}]
77 + end
78 +
79 + test "follows the newest stats row when a repo's leading language changed" do
80 + {_user, repo} = repository_fixture()
81 +
82 + stats(repo, "Ruby", computed_at: DateTime.add(DateTime.utc_now(:second), -3600))
83 + stats(repo, "Elixir")
84 +
85 + backfill()
86 + assert tags(repo) == [{"elixir", "language"}]
87 + end
88 +
89 + test "does not disturb manual tags" do
90 + {_user, repo} = repository_fixture()
91 + {:ok, _} = Tags.set_tags(repo, ["forge", "elixir"])
92 + stats(repo, "Rust")
93 +
94 + backfill()
95 +
96 + names = Tags.tag_names(repo)
97 + assert "forge" in names
98 + assert "rust" in names
99 + assert Tags.manual_tag_names(repo) |> Enum.sort() == ["elixir", "forge"]
100 + end
101 +
102 + test "reports each tagged repo through :on_tag" do
103 + {_user, repo} = repository_fixture()
104 + stats(repo, "Elixir")
105 +
106 + parent = self()
107 +
108 + backfill(on_tag: fn r, tag -> send(parent, {:tagged, r.id, tag}) end)
109 +
110 + assert_receive {:tagged, id, "elixir"}
111 + assert id == repo.id
112 + end
113 +
114 + test "an instance with nothing computed yet is a no-op, not a purge" do
115 + {_user, repo} = repository_fixture()
116 + {:ok, _} = Tags.set_language_tag(repo, "Elixir")
117 +
118 + # Nothing anywhere has stats, so nothing is tagged — and nothing
119 + # is cleared either.
120 + assert {0, _skipped} = backfill()
121 + assert tags(repo) == [{"elixir", "language"}]
122 + end
123 + end
124 +end

Parents: 9049eaf