neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Status badges, and a badge sheet to build READMEs with
0d6bdf0 · gmorell · 2026-11-10 00:12
Message
{commit_body(@commit)}
Files changed
modified
.forgejo/workflows/clippy.yml
+1
−1
| 39 | 39 | - name: Clippy |
| 40 | 40 | run: | |
| 41 | 41 | set -eu |
| 42 | − for crate in native/gitgud_gix native/gitgud_apt; do | |
| 42 | + for crate in native/gitgud_gix native/gitgud_apt native/gitgud_badge; do | |
| 43 | 43 | echo "::group::$crate" |
| 44 | 44 | (cd "$crate" && cargo clippy --workspace --all-targets --locked -- -D warnings) |
| 45 | 45 | echo "::endgroup::" |
modified
Dockerfile
+6
−1
| 87 | 87 | |
| 88 | 88 | COPY lib lib |
| 89 | 89 | |
| 90 | −# Rust source for the NIF crates (gitgud_gix, gitgud_apt) — Rustler | |
| 90 | +# Rust source for the NIF crates (gitgud_gix, gitgud_apt, gitgud_badge) — Rustler | |
| 91 | 91 | # invokes `cargo` from this directory during `mix compile`, so it must |
| 92 | 92 | # be present. gitgud_apt links libssl/libcrypto, which `libssl-dev` |
| 93 | 93 | # above already provides for the builder and the runner's `openssl` |
| 95 | 95 | # precisely so neither image needs a new package. |
| 96 | 96 | COPY native native |
| 97 | 97 | |
| 98 | +# The badge palette (GitGud.Badges.Palette) reads the theme tokens from | |
| 99 | +# app.css at compile time, so the stylesheet has to be here before | |
| 100 | +# `mix compile`; the rest of assets/ still arrives after it. | |
| 101 | +COPY assets/css/app.css assets/css/app.css | |
| 102 | + | |
| 98 | 103 | # Compile the release |
| 99 | 104 | RUN mix compile |
| 100 | 105 |
added
lib/git_gud/badges.ex
+731
−0
| 1 | +defmodule GitGud.Badges do | |
| 2 | + @moduledoc """ | |
| 3 | + Status badges for a repository's default branch, to embed in a README | |
| 4 | + or anywhere else that takes an image. | |
| 5 | + | |
| 6 | + Kinds: | |
| 7 | + | |
| 8 | + * `ci`: the newest push to the default branch, all of its runs | |
| 9 | + together (scheduled and hand-run runs have their own badges, as | |
| 10 | + `workflow/<name>`): `failing` if any failed, else `running`, `queued`, | |
| 11 | + `cancelled` (all of them), or `passing` | |
| 12 | + * `last-commit`: days since the default branch's head was committed; | |
| 13 | + green under 30 days, yellow under 180, red after | |
| 14 | + * `coverage`: the newest `coverage` commit status on the default | |
| 15 | + branch (`Workflows` writes one when a job sets the | |
| 16 | + `gitgud-coverage` output); green from 80%, yellow from 60% | |
| 17 | + * `workflow/<name>`: the newest run of one workflow on the default | |
| 18 | + branch, whatever started it (a push, the nightly schedule, a | |
| 19 | + hand-run dispatch), named by its `name:` or its file's basename. | |
| 20 | + Its jobs decide together, the worst first: `failing`, `warning`, | |
| 21 | + `running`, `queued`, `cancelled`, `passing`. A job can say what it | |
| 22 | + found instead, with a `gitgud-summary` output (`2 to fix, 1 no | |
| 23 | + fix`), and the badge shows those of the jobs at the worst level. | |
| 24 | + * `package/<format>/<name>`: `install with cargo`, and the | |
| 25 | + package's latest version | |
| 26 | + * `issues`, `pulls`: how many are open | |
| 27 | + * `commits`: how many commits the default branch's history holds | |
| 28 | + * `contributors`: how many people authored the commits GitGud has seen | |
| 29 | + * `ci-duration`: how long the newest finished push to the default | |
| 30 | + branch took, first run started to last run done | |
| 31 | + * `release`: the newest tag (highest version, for tags that are | |
| 32 | + versions) | |
| 33 | + * `license`: the license detected at the default branch's tip | |
| 34 | + * `mirror`: for a repository that follows an upstream, how long | |
| 35 | + since it last synced; green within 2 days, yellow within 14, red | |
| 36 | + after that or when the last sync failed | |
| 37 | + * `language`: the top language and its share of the code | |
| 38 | + * `syms`: each sym and how many people put it on the repository | |
| 39 | + * `mark`: just the GG | |
| 40 | + | |
| 41 | + `GitGud.Badges.Spec` parses the URL, `GitGud.Badges.Svg` draws it, and | |
| 42 | + PNGs are that same SVG rasterized by `GitGud.Badges.Native`. | |
| 43 | + """ | |
| 44 | + | |
| 45 | + import Ecto.Query | |
| 46 | + | |
| 47 | + alias GitGud.Badges.Native | |
| 48 | + alias GitGud.Badges.Spec | |
| 49 | + alias GitGud.Badges.Svg | |
| 50 | + alias GitGud.Git | |
| 51 | + alias GitGud.Issues | |
| 52 | + alias GitGud.Packages | |
| 53 | + alias GitGud.PullRequests | |
| 54 | + alias GitGud.Repo | |
| 55 | + alias GitGud.Repositories.GitCommit | |
| 56 | + alias GitGud.Repositories.GitCommitLanguageStats | |
| 57 | + alias GitGud.Repositories.Repository | |
| 58 | + alias GitGud.Syms | |
| 59 | + alias GitGud.Workflows.CommitStatus | |
| 60 | + alias GitGud.Workflows.WorkflowJob | |
| 61 | + alias GitGud.Workflows.WorkflowRun | |
| 62 | + | |
| 63 | + @coverage_context "coverage" | |
| 64 | + | |
| 65 | + @doc "The commit status context coverage is recorded under." | |
| 66 | + def coverage_context, do: @coverage_context | |
| 67 | + | |
| 68 | + @doc """ | |
| 69 | + The badge for `repo` per `spec`, as `{:ok, content_type, body}`. | |
| 70 | + | |
| 71 | + A PNG can't follow the reader's light/dark setting, so `auto` renders | |
| 72 | + as the `dark` theme there; the sheet offers `<picture>` markup with | |
| 73 | + both for that. | |
| 74 | + """ | |
| 75 | + @spec render(Repository.t(), Spec.t()) :: {:ok, String.t(), binary()} | {:error, term()} | |
| 76 | + def render(%Repository{} = repo, %Spec{kind: "workflow", name: name} = spec) do | |
| 77 | + render_content(workflow(repo, name), spec) | |
| 78 | + end | |
| 79 | + | |
| 80 | + def render(%Repository{} = repo, %Spec{kind: "package"} = spec) do | |
| 81 | + render_content(package(repo, spec.registry, spec.name), spec) | |
| 82 | + end | |
| 83 | + | |
| 84 | + def render(%Repository{} = repo, %Spec{} = spec) do | |
| 85 | + render_content(content(repo, spec.kind), spec) | |
| 86 | + end | |
| 87 | + | |
| 88 | + @doc "`content` drawn per `spec`, as `{:ok, content_type, body}`." | |
| 89 | + def render_content(content, %Spec{format: "svg", theme: "auto"} = spec), | |
| 90 | + do: {:ok, "image/svg+xml", Svg.render(content, spec, :auto)} | |
| 91 | + | |
| 92 | + def render_content(content, %Spec{format: "svg", theme: theme} = spec), | |
| 93 | + do: {:ok, "image/svg+xml", Svg.render(content, spec, {:theme, theme})} | |
| 94 | + | |
| 95 | + def render_content(content, %Spec{format: "png"} = spec) do | |
| 96 | + theme = if spec.theme == "auto", do: GitGud.Themes.system_dark(), else: spec.theme | |
| 97 | + | |
| 98 | + with {:ok, png} <- content |> Svg.render(spec, {:theme, theme}) |> Native.rasterize(1.0) do | |
| 99 | + {:ok, "image/png", png} | |
| 100 | + end | |
| 101 | + end | |
| 102 | + | |
| 103 | + @doc """ | |
| 104 | + What a badge of `kind` says about `repo` right now: | |
| 105 | + `%{label:, value:, slot:}`, or `:mark`. | |
| 106 | + """ | |
| 107 | + @spec content(Repository.t(), String.t()) :: Svg.content() | |
| 108 | + def content(_repo, "mark"), do: :mark | |
| 109 | + def content(repo, "ci"), do: %{label: "ci", value: ci_value(repo), slot: nil} |> ci_slot() | |
| 110 | + | |
| 111 | + def content(repo, "last-commit") do | |
| 112 | + case head_commit(repo) do | |
| 113 | + {:ok, %{committed_at: at}} -> age(DateTime.diff(DateTime.utc_now(), at, :day)) | |
| 114 | + _ -> %{label: "last commit", value: "none", slot: "muted"} | |
| 115 | + end | |
| 116 | + end | |
| 117 | + | |
| 118 | + def content(repo, "issues"), do: count("issues", Issues.count_open(repo), "open") | |
| 119 | + def content(repo, "pulls"), do: count("pulls", PullRequests.count_open(repo), "open") | |
| 120 | + | |
| 121 | + def content(repo, "commits") do | |
| 122 | + case head_commit_count(repo) do | |
| 123 | + {:ok, n} -> count("commits", n, nil) | |
| 124 | + _ -> %{label: "commits", value: "none", slot: "muted"} | |
| 125 | + end | |
| 126 | + end | |
| 127 | + | |
| 128 | + def content(repo, "contributors") do | |
| 129 | + n = | |
| 130 | + Repo.one( | |
| 131 | + from c in GitCommit, | |
| 132 | + where: c.repository_id == ^repo.id, | |
| 133 | + select: count(c.author_email, :distinct) | |
| 134 | + ) | |
| 135 | + | |
| 136 | + count("contributors", n, nil) | |
| 137 | + end | |
| 138 | + | |
| 139 | + def content(repo, "ci-duration") do | |
| 140 | + case last_push_duration(repo) do | |
| 141 | + nil -> %{label: "ci duration", value: "none", slot: "muted"} | |
| 142 | + seconds -> %{label: "ci duration", value: duration(seconds), slot: "info"} | |
| 143 | + end | |
| 144 | + end | |
| 145 | + | |
| 146 | + def content(repo, "release") do | |
| 147 | + case latest_tag(repo) do | |
| 148 | + nil -> %{label: "release", value: "none", slot: "muted"} | |
| 149 | + tag -> %{label: "release", value: String.slice(tag, 0, 30), slot: "info"} | |
| 150 | + end | |
| 151 | + end | |
| 152 | + | |
| 153 | + def content(%Repository{license: nil}, "license"), | |
| 154 | + do: %{label: "license", value: "none", slot: "muted"} | |
| 155 | + | |
| 156 | + def content(%Repository{license: license}, "license"), | |
| 157 | + do: %{label: "license", value: license, slot: "info"} | |
| 158 | + | |
| 159 | + def content(repo, "mirror"), do: mirror(repo, DateTime.utc_now()) | |
| 160 | + | |
| 161 | + def content(repo, "language") do | |
| 162 | + case top_language(repo) do | |
| 163 | + nil -> %{label: "language", value: "none", slot: "muted"} | |
| 164 | + {language, percent} -> language_badge(language, percent) | |
| 165 | + end | |
| 166 | + end | |
| 167 | + | |
| 168 | + def content(repo, "syms") do | |
| 169 | + counts = Syms.counts(repo) | |
| 170 | + | |
| 171 | + case for(sym <- Syms.symbols(), n = counts[sym], do: "#{sym} #{n}") do | |
| 172 | + [] -> %{label: "syms", value: "none", slot: "muted"} | |
| 173 | + parts -> %{label: "syms", value: Enum.join(parts, " "), slot: "info"} | |
| 174 | + end | |
| 175 | + end | |
| 176 | + | |
| 177 | + def content(repo, "coverage") do | |
| 178 | + case latest_coverage(repo) do | |
| 179 | + nil -> %{label: "coverage", value: "none", slot: "muted"} | |
| 180 | + percent -> coverage(percent) | |
| 181 | + end | |
| 182 | + end | |
| 183 | + | |
| 184 | + @doc "A `last-commit` badge for a commit `days` old." | |
| 185 | + def age(days) when days <= 0, do: %{label: "last commit", value: "today", slot: "success"} | |
| 186 | + | |
| 187 | + def age(days) do | |
| 188 | + slot = | |
| 189 | + cond do | |
| 190 | + days < 30 -> "success" | |
| 191 | + days < 180 -> "warning" | |
| 192 | + true -> "error" | |
| 193 | + end | |
| 194 | + | |
| 195 | + unit = if days == 1, do: "day", else: "days" | |
| 196 | + %{label: "last commit", value: "#{days} #{unit}", slot: slot} | |
| 197 | + end | |
| 198 | + | |
| 199 | + @doc "A `coverage` badge for `percent`." | |
| 200 | + def coverage(percent) do | |
| 201 | + slot = | |
| 202 | + cond do | |
| 203 | + percent >= 80 -> "success" | |
| 204 | + percent >= 60 -> "warning" | |
| 205 | + true -> "error" | |
| 206 | + end | |
| 207 | + | |
| 208 | + %{label: "coverage", value: format_percent(percent), slot: slot} | |
| 209 | + end | |
| 210 | + | |
| 211 | + @doc "A `ci` badge for a run state: passing, failing, running, queued, cancelled or none." | |
| 212 | + def ci(state), do: ci_slot(%{label: "ci", value: state, slot: nil}) | |
| 213 | + | |
| 214 | + # Running is in-flight (info); queued hasn't started and may be stuck | |
| 215 | + # waiting for a runner (warning). | |
| 216 | + defp ci_slot(%{value: value} = content) do | |
| 217 | + slot = | |
| 218 | + case value do | |
| 219 | + "passing" -> "success" | |
| 220 | + "failing" -> "error" | |
| 221 | + "running" -> "info" | |
| 222 | + "queued" -> "warning" | |
| 223 | + _ -> "muted" | |
| 224 | + end | |
| 225 | + | |
| 226 | + %{content | slot: slot} | |
| 227 | + end | |
| 228 | + | |
| 229 | + @sample_keys %{ | |
| 230 | + "ci" => ~w(passing failing running queued cancelled), | |
| 231 | + "workflow" => ~w(passing warning failing), | |
| 232 | + "package" => ~w(cargo pypi oci deb), | |
| 233 | + "issues" => ~w(12), | |
| 234 | + "pulls" => ~w(3), | |
| 235 | + "commits" => ~w(1234), | |
| 236 | + "contributors" => ~w(14), | |
| 237 | + "ci-duration" => ~w(252), | |
| 238 | + "release" => ~w(v1.4.2), | |
| 239 | + "license" => ~w(AGPL-3.0-only), | |
| 240 | + "mirror" => ~w(fresh stale failing), | |
| 241 | + "language" => ~w(Elixir), | |
| 242 | + "syms" => ~w(some), | |
| 243 | + "last-commit" => ~w(2 47 213), | |
| 244 | + "coverage" => ~w(91.2 68.4 41.0 none), | |
| 245 | + "mark" => ~w(gg) | |
| 246 | + } | |
| 247 | + | |
| 248 | + @doc """ | |
| 249 | + The example states of `kind` the badge sheet shows, as keys for | |
| 250 | + `sample/2`. A fixed list, so sample images can be cached for good. | |
| 251 | + """ | |
| 252 | + def sample_keys(kind), do: Map.fetch!(@sample_keys, kind) | |
| 253 | + | |
| 254 | + @doc "An example badge of `kind` in state `key`, from `sample_keys/1`." | |
| 255 | + @spec sample(String.t(), String.t()) :: {:ok, Svg.content()} | :error | |
| 256 | + def sample(kind, key) do | |
| 257 | + if key in Map.get(@sample_keys, kind, []), do: {:ok, sample!(kind, key)}, else: :error | |
| 258 | + end | |
| 259 | + | |
| 260 | + defp sample!("ci", state), do: ci(state) | |
| 261 | + defp sample!("last-commit", days), do: age(String.to_integer(days)) | |
| 262 | + defp sample!("coverage", "none"), do: %{label: "coverage", value: "none", slot: "muted"} | |
| 263 | + defp sample!("coverage", percent), do: percent |> Float.parse() |> elem(0) |> coverage() | |
| 264 | + defp sample!("mark", _), do: :mark | |
| 265 | + defp sample!("package", registry), do: package_badge(registry, "gitgud", "0.4.1") | |
| 266 | + defp sample!("issues", n), do: count("issues", String.to_integer(n), "open") | |
| 267 | + defp sample!("pulls", n), do: count("pulls", String.to_integer(n), "open") | |
| 268 | + defp sample!("commits", n), do: count("commits", String.to_integer(n), nil) | |
| 269 | + defp sample!("contributors", n), do: count("contributors", String.to_integer(n), nil) | |
| 270 | + | |
| 271 | + defp sample!("ci-duration", s), | |
| 272 | + do: %{label: "ci duration", value: duration(String.to_integer(s)), slot: "info"} | |
| 273 | + | |
| 274 | + defp sample!("release", tag), do: %{label: "release", value: tag, slot: "info"} | |
| 275 | + defp sample!("license", license), do: %{label: "license", value: license, slot: "info"} | |
| 276 | + defp sample!("mirror", "fresh"), do: mirror_age(2 * 3600) | |
| 277 | + defp sample!("mirror", "stale"), do: mirror_age(9 * 86_400) | |
| 278 | + defp sample!("mirror", "failing"), do: %{label: "mirror", value: "sync failing", slot: "error"} | |
| 279 | + defp sample!("language", language), do: language_badge(language, 78.4) | |
| 280 | + defp sample!("syms", _), do: %{label: "syms", value: "⭐ 12 🚀 3 ❤️ 5", slot: "info"} | |
| 281 | + | |
| 282 | + defp sample!("workflow", "passing"), do: workflow_badge("audit", :passing, []) | |
| 283 | + defp sample!("workflow", "warning"), do: workflow_badge("audit", :warning, ["1 no fix"]) | |
| 284 | + | |
| 285 | + defp sample!("workflow", "failing"), | |
| 286 | + do: workflow_badge("audit", :failing, ["2 to fix, 1 no fix"]) | |
| 287 | + | |
| 288 | + @doc """ | |
| 289 | + The coverage a job reported through its `gitgud-coverage` output, as | |
| 290 | + a percentage, or `nil` if the value isn't one. Accepts `87.3` and | |
| 291 | + `87.3%`. A fraction isn't guessed at: `0.873` means 0.873%. | |
| 292 | + """ | |
| 293 | + @spec parse_percent(term()) :: float() | nil | |
| 294 | + def parse_percent(value) when is_binary(value) do | |
| 295 | + case Float.parse(String.trim(value)) do | |
| 296 | + {f, rest} when rest in ["", "%"] and f >= 0 and f <= 100 -> f | |
| 297 | + _ -> nil | |
| 298 | + end | |
| 299 | + end | |
| 300 | + | |
| 301 | + def parse_percent(_), do: nil | |
| 302 | + | |
| 303 | + @doc "`87.3%`, one decimal." | |
| 304 | + def format_percent(percent), | |
| 305 | + do: :erlang.float_to_binary(percent * 1.0, decimals: 1) <> "%" | |
| 306 | + | |
| 307 | + # ── packages and counts ─────────────────────────────────────────────── | |
| 308 | + | |
| 309 | + # The tool each registry format is installed with, as its badge names it. | |
| 310 | + @install_tools %{ | |
| 311 | + "oci" => "podman", | |
| 312 | + "pypi" => "pip", | |
| 313 | + "hex" => "mix", | |
| 314 | + "cargo" => "cargo", | |
| 315 | + "deb" => "apt", | |
| 316 | + "rpm" => "dnf", | |
| 317 | + "fdroid" => "F-Droid", | |
| 318 | + "raw" => "curl" | |
| 319 | + } | |
| 320 | + | |
| 321 | + @doc """ | |
| 322 | + The `package/<format>/<name>` badge: `install with cargo`, then the | |
| 323 | + package's name and latest version. `none` when there's no such | |
| 324 | + package or it has no version yet. | |
| 325 | + """ | |
| 326 | + @spec package(Repository.t(), String.t(), String.t()) :: Svg.content() | |
| 327 | + def package(%Repository{} = repo, registry, name) do | |
| 328 | + case Packages.get_package(repo, registry, name) do | |
| 329 | + %{latest_version: version} = pkg when is_binary(version) -> | |
| 330 | + package_badge(registry, pkg.name, version) | |
| 331 | + | |
| 332 | + _ -> | |
| 333 | + %{label: install_label(registry), value: "none", slot: "muted"} | |
| 334 | + end | |
| 335 | + end | |
| 336 | + | |
| 337 | + @doc "The packages `repo` publishes, as `{format, name}`, for the badge sheet." | |
| 338 | + @spec packages(Repository.t()) :: [{String.t(), String.t()}] | |
| 339 | + def packages(%Repository{} = repo) do | |
| 340 | + for pkg <- Packages.list_packages(repo), do: {pkg.format, pkg.name} | |
| 341 | + end | |
| 342 | + | |
| 343 | + defp package_badge(registry, name, version) do | |
| 344 | + %{ | |
| 345 | + label: install_label(registry), | |
| 346 | + value: String.slice("#{name} #{version}", 0, 40), | |
| 347 | + slot: "info" | |
| 348 | + } | |
| 349 | + end | |
| 350 | + | |
| 351 | + defp install_label(registry), do: "install with " <> Map.get(@install_tools, registry, registry) | |
| 352 | + | |
| 353 | + defp count(label, n, suffix) do | |
| 354 | + value = if suffix, do: "#{group_digits(n)} #{suffix}", else: group_digits(n) | |
| 355 | + %{label: label, value: value, slot: "info"} | |
| 356 | + end | |
| 357 | + | |
| 358 | + @doc "`1234567` as `1,234,567`." | |
| 359 | + def group_digits(n) when is_integer(n) do | |
| 360 | + n | |
| 361 | + |> Integer.to_string() | |
| 362 | + |> String.reverse() | |
| 363 | + |> String.graphemes() | |
| 364 | + |> Enum.chunk_every(3) | |
| 365 | + |> Enum.map_join(",", &Enum.join/1) | |
| 366 | + |> String.reverse() | |
| 367 | + end | |
| 368 | + | |
| 369 | + defp head_commit_count(%Repository{disk_path: path} = repo) when is_binary(path) do | |
| 370 | + with {:ok, sha} <- Git.resolve(path, repo.default_branch || "main") do | |
| 371 | + Git.count_commits(path, sha) | |
| 372 | + end | |
| 373 | + end | |
| 374 | + | |
| 375 | + defp head_commit_count(_repo), do: {:error, :no_repository} | |
| 376 | + | |
| 377 | + # ── release, mirror, language, ci duration ──────────────────────────── | |
| 378 | + | |
| 379 | + @doc """ | |
| 380 | + The newest of `repo`'s tags: the highest version among tags that parse | |
| 381 | + as one (`v1.4.2` or `1.4.2`, releases before pre-releases), else the | |
| 382 | + last in natural order (`build-10` after `build-9`). `nil` with no tags. | |
| 383 | + """ | |
| 384 | + @spec latest_tag(Repository.t()) :: String.t() | nil | |
| 385 | + def latest_tag(%Repository{disk_path: path}) when is_binary(path) do | |
| 386 | + case Git.list_refs(path) do | |
| 387 | + {:ok, refs} -> pick_tag(for %{name: "refs/tags/" <> tag} <- refs, do: tag) | |
| 388 | + _ -> nil | |
| 389 | + end | |
| 390 | + end | |
| 391 | + | |
| 392 | + def latest_tag(_repo), do: nil | |
| 393 | + | |
| 394 | + @doc false | |
| 395 | + def pick_tag([]), do: nil | |
| 396 | + | |
| 397 | + def pick_tag(tags) do | |
| 398 | + versions = | |
| 399 | + for tag <- tags, | |
| 400 | + {:ok, version} <- [Version.parse(String.trim_leading(tag, "v"))], | |
| 401 | + do: {version, tag} | |
| 402 | + | |
| 403 | + {releases, pre} = Enum.split_with(versions, fn {v, _} -> v.pre == [] end) | |
| 404 | + | |
| 405 | + case {releases, pre} do | |
| 406 | + {[_ | _], _} -> releases |> Enum.max_by(&elem(&1, 0), Version) |> elem(1) | |
| 407 | + {[], [_ | _]} -> pre |> Enum.max_by(&elem(&1, 0), Version) |> elem(1) | |
| 408 | + {[], []} -> Enum.max_by(tags, &natural_key/1) | |
| 409 | + end | |
| 410 | + end | |
| 411 | + | |
| 412 | + defp natural_key(tag) do | |
| 413 | + ~r/\d+/ | |
| 414 | + |> Regex.split(tag, include_captures: true, trim: true) | |
| 415 | + |> Enum.map(fn part -> | |
| 416 | + case Integer.parse(part) do | |
| 417 | + {n, ""} -> {0, n} | |
| 418 | + _ -> {1, part} | |
| 419 | + end | |
| 420 | + end) | |
| 421 | + end | |
| 422 | + | |
| 423 | + @doc "The `mirror` badge for `repo` as of `now`." | |
| 424 | + def mirror(%Repository{upstream_url: nil}, _now), | |
| 425 | + do: %{label: "mirror", value: "not a mirror", slot: "muted"} | |
| 426 | + | |
| 427 | + def mirror(%Repository{upstream_sync_error: error}, _now) when is_binary(error), | |
| 428 | + do: %{label: "mirror", value: "sync failing", slot: "error"} | |
| 429 | + | |
| 430 | + def mirror(%Repository{upstream_synced_at: nil}, _now), | |
| 431 | + do: %{label: "mirror", value: "never synced", slot: "warning"} | |
| 432 | + | |
| 433 | + def mirror(%Repository{upstream_synced_at: at}, now), | |
| 434 | + do: mirror_age(max(DateTime.diff(now, at, :second), 0)) | |
| 435 | + | |
| 436 | + defp mirror_age(seconds) do | |
| 437 | + slot = | |
| 438 | + cond do | |
| 439 | + seconds < 2 * 86_400 -> "success" | |
| 440 | + seconds < 14 * 86_400 -> "warning" | |
| 441 | + true -> "error" | |
| 442 | + end | |
| 443 | + | |
| 444 | + %{label: "mirror", value: "synced #{ago(seconds)}", slot: slot} | |
| 445 | + end | |
| 446 | + | |
| 447 | + defp ago(s) when s < 3600, do: "#{max(div(s, 60), 1)}m ago" | |
| 448 | + defp ago(s) when s < 2 * 86_400, do: "#{div(s, 3600)}h ago" | |
| 449 | + defp ago(s), do: "#{div(s, 86_400)}d ago" | |
| 450 | + | |
| 451 | + # The newest computed language stats, rather than the default branch | |
| 452 | + # tip's: a badge shouldn't queue a stats job on every fetch, and a | |
| 453 | + # push since the last run moves the share by a rounding error. | |
| 454 | + defp top_language(repo) do | |
| 455 | + row = | |
| 456 | + from(l in GitCommitLanguageStats, | |
| 457 | + where: l.repository_id == ^repo.id, | |
| 458 | + order_by: [desc: l.computed_at], | |
| 459 | + limit: 1 | |
| 460 | + ) | |
| 461 | + |> Repo.one() | |
| 462 | + | |
| 463 | + case row do | |
| 464 | + %{languages: %{"items" => [%{"language" => lang} = top | _]}, total_bytes: total} | |
| 465 | + when is_binary(lang) and is_integer(total) and total > 0 -> | |
| 466 | + {lang, (top["bytes"] || 0) * 100 / total} | |
| 467 | + | |
| 468 | + _ -> | |
| 469 | + nil | |
| 470 | + end | |
| 471 | + end | |
| 472 | + | |
| 473 | + defp language_badge(language, percent) do | |
| 474 | + %{ | |
| 475 | + label: "language", | |
| 476 | + value: | |
| 477 | + "#{String.slice(language, 0, 20)} #{:erlang.float_to_binary(percent * 1.0, decimals: 0)}%", | |
| 478 | + slot: "info" | |
| 479 | + } | |
| 480 | + end | |
| 481 | + | |
| 482 | + # Seconds the newest push whose runs have all finished took, from its | |
| 483 | + # first run starting to its last finishing. A push still running is | |
| 484 | + # skipped for the one before it: the badge shows a duration, not a | |
| 485 | + # stopwatch. | |
| 486 | + defp last_push_duration(repo) do | |
| 487 | + ref = branch_ref(repo) | |
| 488 | + | |
| 489 | + from(r in WorkflowRun, | |
| 490 | + where: | |
| 491 | + r.repository_id == ^repo.id and r.head_ref == ^ref and r.trigger == "push" and | |
| 492 | + is_nil(r.pull_request_number), | |
| 493 | + order_by: [desc: r.inserted_at, desc: r.id], | |
| 494 | + limit: 50 | |
| 495 | + ) | |
| 496 | + |> Repo.all() | |
| 497 | + |> Enum.chunk_by(& &1.event_id) | |
| 498 | + |> Enum.find(fn runs -> Enum.all?(runs, & &1.completed_at) end) | |
| 499 | + |> case do | |
| 500 | + nil -> | |
| 501 | + nil | |
| 502 | + | |
| 503 | + runs -> | |
| 504 | + start = runs |> Enum.map(&(&1.started_at || &1.inserted_at)) |> Enum.min(DateTime) | |
| 505 | + finish = runs |> Enum.map(& &1.completed_at) |> Enum.max(DateTime) | |
| 506 | + max(DateTime.diff(finish, start, :second), 0) | |
| 507 | + end | |
| 508 | + end | |
| 509 | + | |
| 510 | + @doc "`252` seconds as `4m 12s`." | |
| 511 | + def duration(s) when s < 60, do: "#{s}s" | |
| 512 | + def duration(s) when s < 3600, do: "#{div(s, 60)}m #{rem(s, 60)}s" | |
| 513 | + def duration(s), do: "#{div(s, 3600)}h #{div(rem(s, 3600), 60)}m" | |
| 514 | + | |
| 515 | + # ── workflows ───────────────────────────────────────────────────────── | |
| 516 | + | |
| 517 | + @summary_output "gitgud-summary" | |
| 518 | + @summary_max 40 | |
| 519 | + | |
| 520 | + # Worst first. A job's status, to the badge's state. | |
| 521 | + @job_states [ | |
| 522 | + {"failure", :failing}, | |
| 523 | + {"warning", :warning}, | |
| 524 | + {"running", :running}, | |
| 525 | + {"queued", :queued}, | |
| 526 | + {"blocked", :queued}, | |
| 527 | + {"waiting", :queued}, | |
| 528 | + {"awaiting_approval", :queued}, | |
| 529 | + {"cancelled", :cancelled}, | |
| 530 | + {"success", :passing}, | |
| 531 | + {"skipped", :passing} | |
| 532 | + ] | |
| 533 | + @state_rank @job_states | |
| 534 | + |> Enum.map(&elem(&1, 1)) | |
| 535 | + |> Enum.uniq() | |
| 536 | + |> Enum.with_index() | |
| 537 | + |> Map.new() | |
| 538 | + | |
| 539 | + @doc """ | |
| 540 | + The `workflow/<name>` badge: the newest run of the workflow called | |
| 541 | + `name` on `repo`'s default branch, from anything but a pull request. | |
| 542 | + """ | |
| 543 | + @spec workflow(Repository.t(), String.t()) :: Svg.content() | |
| 544 | + def workflow(%Repository{} = repo, name) do | |
| 545 | + case latest_workflow_run(repo, name) do | |
| 546 | + nil -> | |
| 547 | + %{label: label(name), value: "none", slot: "muted"} | |
| 548 | + | |
| 549 | + run -> | |
| 550 | + jobs = Repo.all(from j in WorkflowJob, where: j.workflow_run_id == ^run.id) | |
| 551 | + {state, summaries} = workflow_state(run, jobs) | |
| 552 | + workflow_badge(name, state, summaries) | |
| 553 | + end | |
| 554 | + end | |
| 555 | + | |
| 556 | + defp workflow_state(run, []), do: {job_state(run.status), []} | |
| 557 | + | |
| 558 | + defp workflow_state(_run, jobs) do | |
| 559 | + states = Enum.map(jobs, &{job_state(&1.status), &1}) | |
| 560 | + worst = states |> Enum.map(&elem(&1, 0)) |> Enum.min_by(&Map.fetch!(@state_rank, &1)) | |
| 561 | + | |
| 562 | + summaries = | |
| 563 | + for {^worst, job} <- states, | |
| 564 | + worst != :passing, | |
| 565 | + summary = job_summary(job), | |
| 566 | + do: summary | |
| 567 | + | |
| 568 | + {worst, Enum.uniq(summaries)} | |
| 569 | + end | |
| 570 | + | |
| 571 | + defp job_state(status) do | |
| 572 | + Enum.find_value(@job_states, :queued, fn {s, state} -> if s == status, do: state end) | |
| 573 | + end | |
| 574 | + | |
| 575 | + defp workflow_badge(name, state, summaries) do | |
| 576 | + {word, slot} = | |
| 577 | + case state do | |
| 578 | + :failing -> {"failing", "error"} | |
| 579 | + :warning -> {"warning", "warning"} | |
| 580 | + :running -> {"running", "info"} | |
| 581 | + :queued -> {"queued", "warning"} | |
| 582 | + :cancelled -> {"cancelled", "muted"} | |
| 583 | + :passing -> {"passing", "success"} | |
| 584 | + end | |
| 585 | + | |
| 586 | + value = | |
| 587 | + case summaries do | |
| 588 | + [] -> word | |
| 589 | + _ -> summaries |> Enum.join(", ") |> String.slice(0, @summary_max) | |
| 590 | + end | |
| 591 | + | |
| 592 | + %{label: label(name), value: value, slot: slot} | |
| 593 | + end | |
| 594 | + | |
| 595 | + defp label(name), do: String.slice(name, 0, 30) | |
| 596 | + | |
| 597 | + @doc """ | |
| 598 | + What a finished job said it found, through its `gitgud-summary` | |
| 599 | + output: trimmed, one line, at most #{@summary_max} characters. `nil` | |
| 600 | + when it said nothing. | |
| 601 | + """ | |
| 602 | + @spec job_summary(WorkflowJob.t()) :: String.t() | nil | |
| 603 | + def job_summary(%WorkflowJob{outputs: %{@summary_output => value}}) when is_binary(value) do | |
| 604 | + value | |
| 605 | + |> String.replace(~r/[[:cntrl:]]+/u, " ") | |
| 606 | + |> String.trim() | |
| 607 | + |> String.slice(0, @summary_max) | |
| 608 | + |> case do | |
| 609 | + "" -> nil | |
| 610 | + summary -> summary | |
| 611 | + end | |
| 612 | + end | |
| 613 | + | |
| 614 | + def job_summary(_job), do: nil | |
| 615 | + | |
| 616 | + @doc """ | |
| 617 | + The workflows that have run on `repo`'s default branch lately, by the | |
| 618 | + name their badge takes, for the badge sheet. | |
| 619 | + """ | |
| 620 | + @spec workflow_names(Repository.t()) :: [String.t()] | |
| 621 | + def workflow_names(%Repository{} = repo) do | |
| 622 | + ref = branch_ref(repo) | |
| 623 | + | |
| 624 | + from(r in WorkflowRun, | |
| 625 | + where: | |
| 626 | + r.repository_id == ^repo.id and r.head_ref == ^ref and is_nil(r.pull_request_number) and | |
| 627 | + r.trigger != "pull_request", | |
| 628 | + order_by: [desc: r.inserted_at], | |
| 629 | + limit: 200, | |
| 630 | + select: {r.workflow_name, r.workflow_path} | |
| 631 | + ) | |
| 632 | + |> Repo.all() | |
| 633 | + |> Enum.map(&run_name/1) | |
| 634 | + |> Enum.reject(&is_nil/1) | |
| 635 | + |> Enum.uniq() | |
| 636 | + |> Enum.sort() | |
| 637 | + end | |
| 638 | + | |
| 639 | + defp run_name({name, _path}) when is_binary(name) and name != "", do: name | |
| 640 | + | |
| 641 | + defp run_name({_name, path}) when is_binary(path), | |
| 642 | + do: path |> Path.basename() |> Path.rootname() | |
| 643 | + | |
| 644 | + defp run_name(_), do: nil | |
| 645 | + | |
| 646 | + defp latest_workflow_run(repo, name) do | |
| 647 | + ref = branch_ref(repo) | |
| 648 | + files = [name <> ".yml", name <> ".yaml"] | |
| 649 | + | |
| 650 | + from(r in WorkflowRun, | |
| 651 | + where: | |
| 652 | + r.repository_id == ^repo.id and r.head_ref == ^ref and is_nil(r.pull_request_number) and | |
| 653 | + r.trigger != "pull_request", | |
| 654 | + where: | |
| 655 | + r.workflow_name == ^name or | |
| 656 | + (is_nil(r.workflow_name) and | |
| 657 | + fragment("regexp_replace(?, '^.*/', '')", r.workflow_path) in ^files), | |
| 658 | + order_by: [desc: r.inserted_at, desc: r.id], | |
| 659 | + limit: 1 | |
| 660 | + ) | |
| 661 | + |> Repo.one() | |
| 662 | + end | |
| 663 | + | |
| 664 | + # ── data ────────────────────────────────────────────────────────────── | |
| 665 | + | |
| 666 | + defp branch_ref(repo), do: "refs/heads/" <> (repo.default_branch || "main") | |
| 667 | + | |
| 668 | + defp head_commit(%Repository{disk_path: path} = repo) when is_binary(path) do | |
| 669 | + with {:ok, sha} <- Git.resolve(path, repo.default_branch || "main") do | |
| 670 | + Git.commit(path, sha) | |
| 671 | + end | |
| 672 | + end | |
| 673 | + | |
| 674 | + defp head_commit(_repo), do: {:error, :no_repository} | |
| 675 | + | |
| 676 | + defp ci_value(repo) do | |
| 677 | + ref = branch_ref(repo) | |
| 678 | + | |
| 679 | + latest = | |
| 680 | + from(r in WorkflowRun, | |
| 681 | + where: | |
| 682 | + r.repository_id == ^repo.id and r.head_ref == ^ref and r.trigger == "push" and | |
| 683 | + is_nil(r.pull_request_number), | |
| 684 | + order_by: [desc: r.inserted_at, desc: r.id], | |
| 685 | + limit: 1 | |
| 686 | + ) | |
| 687 | + |> Repo.one() | |
| 688 | + | |
| 689 | + case latest do | |
| 690 | + nil -> "none" | |
| 691 | + run -> run |> event_runs() |> Enum.map(& &1.status) |> ci_state() | |
| 692 | + end | |
| 693 | + end | |
| 694 | + | |
| 695 | + defp event_runs(%WorkflowRun{event_id: nil} = run), do: [run] | |
| 696 | + | |
| 697 | + defp event_runs(%WorkflowRun{event_id: event_id, repository_id: rid}) do | |
| 698 | + Repo.all(from r in WorkflowRun, where: r.repository_id == ^rid and r.event_id == ^event_id) | |
| 699 | + end | |
| 700 | + | |
| 701 | + defp ci_state(statuses) do | |
| 702 | + cond do | |
| 703 | + "failure" in statuses -> "failing" | |
| 704 | + "running" in statuses -> "running" | |
| 705 | + "queued" in statuses -> "queued" | |
| 706 | + Enum.all?(statuses, &(&1 == "cancelled")) -> "cancelled" | |
| 707 | + true -> "passing" | |
| 708 | + end | |
| 709 | + end | |
| 710 | + | |
| 711 | + defp latest_coverage(repo) do | |
| 712 | + ref = branch_ref(repo) | |
| 713 | + | |
| 714 | + shas = | |
| 715 | + from(r in WorkflowRun, | |
| 716 | + where: r.repository_id == ^repo.id and r.head_ref == ^ref, | |
| 717 | + select: r.head_sha | |
| 718 | + ) | |
| 719 | + | |
| 720 | + from(c in CommitStatus, | |
| 721 | + where: | |
| 722 | + c.repository_id == ^repo.id and c.context == @coverage_context and | |
| 723 | + c.sha in subquery(shas), | |
| 724 | + order_by: [desc: c.updated_at, desc: c.id], | |
| 725 | + limit: 1, | |
| 726 | + select: c.description | |
| 727 | + ) | |
| 728 | + |> Repo.one() | |
| 729 | + |> parse_percent() | |
| 730 | + end | |
| 731 | +end |
added
lib/git_gud/badges/native.ex
+39
−0
| 1 | +defmodule GitGud.Badges.Native do | |
| 2 | + @moduledoc """ | |
| 3 | + The `native/gitgud_badge` Rustler NIF: B612 Mono glyph outlines and | |
| 4 | + SVG-to-PNG rendering. | |
| 5 | + | |
| 6 | + Badge lettering is drawn as paths instead of `<text>`. An SVG that | |
| 7 | + another site loads through `<img>` can't fetch a web font, so `<text>` | |
| 8 | + would render in whatever monospace the reader happens to have, and | |
| 9 | + the PNG renderer would need a font stack of its own. As outlines, the | |
| 10 | + SVG and the PNG are the same drawing everywhere. The font (SIL OFL | |
| 11 | + 1.1) is compiled into the NIF; `resvg` is built without its text | |
| 12 | + features, so neither image needs fontconfig or any other system | |
| 13 | + library. | |
| 14 | + """ | |
| 15 | + | |
| 16 | + use Rustler, otp_app: :git_gud, crate: "gitgud_badge" | |
| 17 | + | |
| 18 | + @doc "One B612 Mono character's advance as a fraction of the font size." | |
| 19 | + @spec advance_em() :: float() | |
| 20 | + def advance_em, do: :erlang.nif_error(:nif_not_loaded) | |
| 21 | + | |
| 22 | + @doc """ | |
| 23 | + How wide `text` draws at `size` px. B612 Mono is monospaced, but the | |
| 24 | + emoji it falls back to for what it lacks aren't. | |
| 25 | + """ | |
| 26 | + @spec text_width(String.t(), boolean(), float()) :: float() | |
| 27 | + def text_width(_text, _bold, _size), do: :erlang.nif_error(:nif_not_loaded) | |
| 28 | + | |
| 29 | + @doc """ | |
| 30 | + `text` as an SVG path `d`, at `size` px, starting at `x` on `baseline`. | |
| 31 | + All three numbers must be floats. | |
| 32 | + """ | |
| 33 | + @spec text_path(String.t(), boolean(), float(), float(), float()) :: String.t() | |
| 34 | + def text_path(_text, _bold, _size, _x, _baseline), do: :erlang.nif_error(:nif_not_loaded) | |
| 35 | + | |
| 36 | + @doc "PNG bytes for `svg`, rendered at `zoom` times its own size." | |
| 37 | + @spec rasterize(String.t(), float()) :: {:ok, binary()} | {:error, String.t()} | |
| 38 | + def rasterize(_svg, _zoom), do: :erlang.nif_error(:nif_not_loaded) | |
| 39 | +end |
added
lib/git_gud/badges/oklch.ex
+61
−0
| 1 | +defmodule GitGud.Badges.Oklch do | |
| 2 | + @moduledoc """ | |
| 3 | + oklch to sRGB, and WCAG contrast. | |
| 4 | + | |
| 5 | + The themes in `assets/css/app.css` are written in oklch, which a | |
| 6 | + browser resolves itself. A badge that leaves the browser (a PNG, an | |
| 7 | + SVG on someone else's site) needs concrete sRGB, so the conversion | |
| 8 | + happens here, once, at compile time (`GitGud.Badges.Palette`). | |
| 9 | + | |
| 10 | + Björn Ottosson's oklab matrices; colors outside sRGB are clipped per | |
| 11 | + channel, which is what browsers did before CSS Color 4 gamut mapping | |
| 12 | + and is close enough for the few saturated tokens it touches. | |
| 13 | + """ | |
| 14 | + | |
| 15 | + @type lin :: {float(), float(), float()} | |
| 16 | + | |
| 17 | + @doc "Linear sRGB channels, each clipped to 0..1, for `oklch(l% c h)`." | |
| 18 | + @spec to_linear(number(), number(), number()) :: lin() | |
| 19 | + def to_linear(l_pct, c, h) do | |
| 20 | + l = l_pct / 100 | |
| 21 | + rad = h * :math.pi() / 180 | |
| 22 | + a = c * :math.cos(rad) | |
| 23 | + b = c * :math.sin(rad) | |
| 24 | + | |
| 25 | + l_ = :math.pow(l + 0.3963377774 * a + 0.2158037573 * b, 3) | |
| 26 | + m_ = :math.pow(l - 0.1055613458 * a - 0.0638541728 * b, 3) | |
| 27 | + s_ = :math.pow(l - 0.0894841775 * a - 1.2914855480 * b, 3) | |
| 28 | + | |
| 29 | + { | |
| 30 | + clip(4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_), | |
| 31 | + clip(-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_), | |
| 32 | + clip(-0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_) | |
| 33 | + } | |
| 34 | + end | |
| 35 | + | |
| 36 | + @doc "`#rrggbb` for linear channels." | |
| 37 | + @spec hex(lin()) :: String.t() | |
| 38 | + def hex({r, g, b}) do | |
| 39 | + "#" <> Enum.map_join([r, g, b], &byte/1) | |
| 40 | + end | |
| 41 | + | |
| 42 | + @doc "WCAG relative luminance of linear channels." | |
| 43 | + @spec luminance(lin()) :: float() | |
| 44 | + def luminance({r, g, b}), do: 0.2126 * r + 0.7152 * g + 0.0722 * b | |
| 45 | + | |
| 46 | + @doc "WCAG contrast ratio between two luminances, 1.0 to 21.0." | |
| 47 | + @spec contrast(float(), float()) :: float() | |
| 48 | + def contrast(y1, y2), do: (max(y1, y2) + 0.05) / (min(y1, y2) + 0.05) | |
| 49 | + | |
| 50 | + defp clip(v), do: v |> max(0.0) |> min(1.0) | |
| 51 | + | |
| 52 | + defp byte(v) do | |
| 53 | + encoded = if v <= 0.0031308, do: 12.92 * v, else: 1.055 * :math.pow(v, 1 / 2.4) - 0.055 | |
| 54 | + | |
| 55 | + (encoded * 255) | |
| 56 | + |> round() | |
| 57 | + |> Integer.to_string(16) | |
| 58 | + |> String.downcase() | |
| 59 | + |> String.pad_leading(2, "0") | |
| 60 | + end | |
| 61 | +end |
added
lib/git_gud/badges/palette.ex
+273
−0
Click to load diff…
added
lib/git_gud/badges/spec.ex
+192
−0
Click to load diff…
added
lib/git_gud/badges/svg.ex
+230
−0
Click to load diff…
modified
lib/git_gud/git.ex
+7
−0
Click to load diff…
modified
lib/git_gud/git/backend.ex
+2
−0
Click to load diff…
modified
lib/git_gud/git/cli.ex
+11
−0
Click to load diff…
modified
lib/git_gud/git/nif.ex
+3
−0
Click to load diff…
modified
lib/git_gud/workflows.ex
+57
−1
Click to load diff…
added
lib/git_gud_web/badge_catalog.ex
+275
−0
Click to load diff…
added
lib/git_gud_web/controllers/api/mobile/badge_controller.ex
+109
−0
Click to load diff…
added
lib/git_gud_web/controllers/badge_controller.ex
+104
−0
Click to load diff…
added
lib/git_gud_web/live/repo_live/badges.ex
+824
−0
Click to load diff…
modified
lib/git_gud_web/live/repo_live/header.ex
+8
−0
Click to load diff…
modified
lib/git_gud_web/router.ex
+15
−0
Click to load diff…
added
native/gitgud_badge/.gitignore
+1
−0
Click to load diff…
added
native/gitgud_badge/Cargo.lock
+471
−0
Click to load diff…
added
native/gitgud_badge/Cargo.toml
+26
−0
Click to load diff…
modified
?
+0
−0
Click to load diff…
modified
?
+0
−0
Click to load diff…
modified
?
+0
−0
Click to load diff…
added
native/gitgud_badge/fonts/OFL-NotoEmoji.txt
+93
−0
Click to load diff…
added
native/gitgud_badge/fonts/OFL.txt
+93
−0
Click to load diff…
added
native/gitgud_badge/src/lib.rs
+231
−0
Click to load diff…
added
test/git_gud/badges/palette_test.exs
+66
−0
Click to load diff…
added
test/git_gud/badges/spec_test.exs
+101
−0
Click to load diff…
added
test/git_gud/badges/svg_test.exs
+101
−0
Click to load diff…
added
test/git_gud/badges_test.exs
+542
−0
Click to load diff…
added
test/git_gud_web/controllers/api/mobile/mobile_badges_test.exs
+90
−0
Click to load diff…
added
test/git_gud_web/controllers/badge_controller_test.exs
+118
−0
Click to load diff…
added
test/git_gud_web/live/repo_live/badges_test.exs
+220
−0
Click to load diff…
Parents: da4df18