neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Make the language-tag backfill runnable in a release
3fb3f48 · Gabriel Morell · 2026-09-12 03:11
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