Status badges, and a badge sheet to build READMEs with

0d6bdf0 · gmorell · 2026-11-10 00:12

60 files +5101 -3
Message
{commit_body(@commit)}

Files changed

modified .forgejo/workflows/clippy.yml
+1 −1
@@ -39,7 +39,7 @@ jobs:
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,7 +87,7 @@ COPY priv priv
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,6 +95,11 @@ COPY lib lib
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
@@ -0,0 +1,731 @@
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
@@ -0,0 +1,39 @@
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
@@ -0,0 +1,61 @@
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