defmodule GitGud.Badges do
@moduledoc """
Status badges for a repository's default branch, to embed in a README
or anywhere else that takes an image.
Kinds:
* `ci`: the newest push to the default branch, all of its runs
together (scheduled and hand-run runs have their own badges, as
`workflow/<name>`): `failing` if any failed, else `running`, `queued`,
`cancelled` (all of them), or `passing`
* `last-commit`: days since the default branch's head was committed;
green under 30 days, yellow under 180, red after
* `coverage`: the newest `coverage` commit status on the default
branch (`Workflows` writes one when a job sets the
`gitgud-coverage` output); green from 80%, yellow from 60%
* `workflow/<name>`: the newest run of one workflow on the default
branch, whatever started it (a push, the nightly schedule, a
hand-run dispatch), named by its `name:` or its file's basename.
Its jobs decide together, the worst first: `failing`, `warning`,
`running`, `queued`, `cancelled`, `passing`. A job can say what it
found instead, with a `gitgud-summary` output (`2 to fix, 1 no
fix`), and the badge shows those of the jobs at the worst level.
* `package/<format>/<name>`: `install with cargo`, and the
package's latest version
* `issues`, `pulls`: how many are open
* `commits`: how many commits the default branch's history holds
* `contributors`: how many people authored the commits GitGud has seen
* `ci-duration`: how long the newest finished push to the default
branch took, first run started to last run done
* `release`: the newest tag (highest version, for tags that are
versions)
* `license`: the license detected at the default branch's tip
* `mirror`: for a repository that follows an upstream, how long
since it last synced; green within 2 days, yellow within 14, red
after that or when the last sync failed
* `language`: the top language and its share of the code
* `syms`: each sym and how many people put it on the repository
* `mark`: just the GG
`GitGud.Badges.Spec` parses the URL, `GitGud.Badges.Svg` draws it, and
PNGs are that same SVG rasterized by `GitGud.Badges.Native`.
"""
import Ecto.Query
alias GitGud.Badges.Native
alias GitGud.Badges.Spec
alias GitGud.Badges.Svg
alias GitGud.Git
alias GitGud.Issues
alias GitGud.Packages
alias GitGud.PullRequests
alias GitGud.Repo
alias GitGud.Repositories.GitCommit
alias GitGud.Repositories.GitCommitLanguageStats
alias GitGud.Repositories.Repository
alias GitGud.Syms
alias GitGud.Workflows.CommitStatus
alias GitGud.Workflows.WorkflowJob
alias GitGud.Workflows.WorkflowRun
@coverage_context "coverage"
@doc "The commit status context coverage is recorded under."
def coverage_context, do: @coverage_context
@doc """
The badge for `repo` per `spec`, as `{:ok, content_type, body}`.
A PNG can't follow the reader's light/dark setting, so `auto` renders
as the `dark` theme there; the sheet offers `<picture>` markup with
both for that.
"""
@spec render(Repository.t(), Spec.t()) :: {:ok, String.t(), binary()} | {:error, term()}
def render(%Repository{} = repo, %Spec{kind: "workflow", name: name} = spec) do
render_content(workflow(repo, name), spec)
end
def render(%Repository{} = repo, %Spec{kind: "package"} = spec) do
render_content(package(repo, spec.registry, spec.name), spec)
end
def render(%Repository{} = repo, %Spec{} = spec) do
render_content(content(repo, spec.kind), spec)
end
@doc "`content` drawn per `spec`, as `{:ok, content_type, body}`."
def render_content(content, %Spec{format: "svg", theme: "auto"} = spec),
do: {:ok, "image/svg+xml", Svg.render(content, spec, :auto)}
def render_content(content, %Spec{format: "svg", theme: theme} = spec),
do: {:ok, "image/svg+xml", Svg.render(content, spec, {:theme, theme})}
def render_content(content, %Spec{format: "png"} = spec) do
theme = if spec.theme == "auto", do: GitGud.Themes.system_dark(), else: spec.theme
with {:ok, png} <- content |> Svg.render(spec, {:theme, theme}) |> Native.rasterize(1.0) do
{:ok, "image/png", png}
end
end
@doc """
What a badge of `kind` says about `repo` right now:
`%{label:, value:, slot:}`, or `:mark`.
"""
@spec content(Repository.t(), String.t()) :: Svg.content()
def content(_repo, "mark"), do: :mark
def content(repo, "ci"), do: %{label: "ci", value: ci_value(repo), slot: nil} |> ci_slot()
def content(repo, "last-commit") do
case head_commit(repo) do
{:ok, %{committed_at: at}} -> age(DateTime.diff(DateTime.utc_now(), at, :day))
_ -> %{label: "last commit", value: "none", slot: "muted"}
end
end
def content(repo, "issues"), do: count("issues", Issues.count_open(repo), "open")
def content(repo, "pulls"), do: count("pulls", PullRequests.count_open(repo), "open")
def content(repo, "commits") do
case head_commit_count(repo) do
{:ok, n} -> count("commits", n, nil)
_ -> %{label: "commits", value: "none", slot: "muted"}
end
end
def content(repo, "contributors") do
n =
Repo.one(
from c in GitCommit,
where: c.repository_id == ^repo.id,
select: count(c.author_email, :distinct)
)
count("contributors", n, nil)
end
def content(repo, "ci-duration") do
case last_push_duration(repo) do
nil -> %{label: "ci duration", value: "none", slot: "muted"}
seconds -> %{label: "ci duration", value: duration(seconds), slot: "info"}
end
end
def content(repo, "release") do
case latest_tag(repo) do
nil -> %{label: "release", value: "none", slot: "muted"}
tag -> %{label: "release", value: String.slice(tag, 0, 30), slot: "info"}
end
end
def content(%Repository{license: nil}, "license"),
do: %{label: "license", value: "none", slot: "muted"}
def content(%Repository{license: license}, "license"),
do: %{label: "license", value: license, slot: "info"}
def content(repo, "mirror"), do: mirror(repo, DateTime.utc_now())
def content(repo, "language") do
case top_language(repo) do
nil -> %{label: "language", value: "none", slot: "muted"}
{language, percent} -> language_badge(language, percent)
end
end
def content(repo, "syms") do
counts = Syms.counts(repo)
case for(sym <- Syms.symbols(), n = counts[sym], do: "#{sym} #{n}") do
[] -> %{label: "syms", value: "none", slot: "muted"}
parts -> %{label: "syms", value: Enum.join(parts, " "), slot: "info"}
end
end
def content(repo, "coverage") do
case latest_coverage(repo) do
nil -> %{label: "coverage", value: "none", slot: "muted"}
percent -> coverage(percent)
end
end
@doc "A `last-commit` badge for a commit `days` old."
def age(days) when days <= 0, do: %{label: "last commit", value: "today", slot: "success"}
def age(days) do
slot =
cond do
days < 30 -> "success"
days < 180 -> "warning"
true -> "error"
end
unit = if days == 1, do: "day", else: "days"
%{label: "last commit", value: "#{days} #{unit}", slot: slot}
end
@doc "A `coverage` badge for `percent`."
def coverage(percent) do
slot =
cond do
percent >= 80 -> "success"
percent >= 60 -> "warning"
true -> "error"
end
%{label: "coverage", value: format_percent(percent), slot: slot}
end
@doc "A `ci` badge for a run state: passing, failing, running, queued, cancelled or none."
def ci(state), do: ci_slot(%{label: "ci", value: state, slot: nil})
# Running is in-flight (info); queued hasn't started and may be stuck
# waiting for a runner (warning).
defp ci_slot(%{value: value} = content) do
slot =
case value do
"passing" -> "success"
"failing" -> "error"
"running" -> "info"
"queued" -> "warning"
_ -> "muted"
end
%{content | slot: slot}
end
@sample_keys %{
"ci" => ~w(passing failing running queued cancelled),
"workflow" => ~w(passing warning failing),
"package" => ~w(cargo pypi oci deb),
"issues" => ~w(12),
"pulls" => ~w(3),
"commits" => ~w(1234),
"contributors" => ~w(14),
"ci-duration" => ~w(252),
"release" => ~w(v1.4.2),
"license" => ~w(AGPL-3.0-only),
"mirror" => ~w(fresh stale failing),
"language" => ~w(Elixir),
"syms" => ~w(some),
"last-commit" => ~w(2 47 213),
"coverage" => ~w(91.2 68.4 41.0 none),
"mark" => ~w(gg)
}
@doc """
The example states of `kind` the badge sheet shows, as keys for
`sample/2`. A fixed list, so sample images can be cached for good.
"""
def sample_keys(kind), do: Map.fetch!(@sample_keys, kind)
@doc "An example badge of `kind` in state `key`, from `sample_keys/1`."
@spec sample(String.t(), String.t()) :: {:ok, Svg.content()} | :error
def sample(kind, key) do
if key in Map.get(@sample_keys, kind, []), do: {:ok, sample!(kind, key)}, else: :error
end
defp sample!("ci", state), do: ci(state)
defp sample!("last-commit", days), do: age(String.to_integer(days))
defp sample!("coverage", "none"), do: %{label: "coverage", value: "none", slot: "muted"}
defp sample!("coverage", percent), do: percent |> Float.parse() |> elem(0) |> coverage()
defp sample!("mark", _), do: :mark
defp sample!("package", registry), do: package_badge(registry, "gitgud", "0.4.1")
defp sample!("issues", n), do: count("issues", String.to_integer(n), "open")
defp sample!("pulls", n), do: count("pulls", String.to_integer(n), "open")
defp sample!("commits", n), do: count("commits", String.to_integer(n), nil)
defp sample!("contributors", n), do: count("contributors", String.to_integer(n), nil)
defp sample!("ci-duration", s),
do: %{label: "ci duration", value: duration(String.to_integer(s)), slot: "info"}
defp sample!("release", tag), do: %{label: "release", value: tag, slot: "info"}
defp sample!("license", license), do: %{label: "license", value: license, slot: "info"}
defp sample!("mirror", "fresh"), do: mirror_age(2 * 3600)
defp sample!("mirror", "stale"), do: mirror_age(9 * 86_400)
defp sample!("mirror", "failing"), do: %{label: "mirror", value: "sync failing", slot: "error"}
defp sample!("language", language), do: language_badge(language, 78.4)
defp sample!("syms", _), do: %{label: "syms", value: "⭐ 12 🚀 3 ❤️ 5", slot: "info"}
defp sample!("workflow", "passing"), do: workflow_badge("audit", :passing, [])
defp sample!("workflow", "warning"), do: workflow_badge("audit", :warning, ["1 no fix"])
defp sample!("workflow", "failing"),
do: workflow_badge("audit", :failing, ["2 to fix, 1 no fix"])
@doc """
The coverage a job reported through its `gitgud-coverage` output, as
a percentage, or `nil` if the value isn't one. Accepts `87.3` and
`87.3%`. A fraction isn't guessed at: `0.873` means 0.873%.
"""
@spec parse_percent(term()) :: float() | nil
def parse_percent(value) when is_binary(value) do
case Float.parse(String.trim(value)) do
{f, rest} when rest in ["", "%"] and f >= 0 and f <= 100 -> f
_ -> nil
end
end
def parse_percent(_), do: nil
@doc "`87.3%`, one decimal."
def format_percent(percent),
do: :erlang.float_to_binary(percent * 1.0, decimals: 1) <> "%"
# ── packages and counts ───────────────────────────────────────────────
# The tool each registry format is installed with, as its badge names it.
@install_tools %{
"oci" => "podman",
"pypi" => "pip",
"hex" => "mix",
"cargo" => "cargo",
"deb" => "apt",
"rpm" => "dnf",
"fdroid" => "F-Droid",
"raw" => "curl"
}
@doc """
The `package/<format>/<name>` badge: `install with cargo`, then the
package's name and latest version. `none` when there's no such
package or it has no version yet.
"""
@spec package(Repository.t(), String.t(), String.t()) :: Svg.content()
def package(%Repository{} = repo, registry, name) do
case Packages.get_package(repo, registry, name) do
%{latest_version: version} = pkg when is_binary(version) ->
package_badge(registry, pkg.name, version)
_ ->
%{label: install_label(registry), value: "none", slot: "muted"}
end
end
@doc "The packages `repo` publishes, as `{format, name}`, for the badge sheet."
@spec packages(Repository.t()) :: [{String.t(), String.t()}]
def packages(%Repository{} = repo) do
for pkg <- Packages.list_packages(repo), do: {pkg.format, pkg.name}
end
defp package_badge(registry, name, version) do
%{
label: install_label(registry),
value: String.slice("#{name} #{version}", 0, 40),
slot: "info"
}
end
defp install_label(registry), do: "install with " <> Map.get(@install_tools, registry, registry)
defp count(label, n, suffix) do
value = if suffix, do: "#{group_digits(n)} #{suffix}", else: group_digits(n)
%{label: label, value: value, slot: "info"}
end
@doc "`1234567` as `1,234,567`."
def group_digits(n) when is_integer(n) do
n
|> Integer.to_string()
|> String.reverse()
|> String.graphemes()
|> Enum.chunk_every(3)
|> Enum.map_join(",", &Enum.join/1)
|> String.reverse()
end
defp head_commit_count(%Repository{disk_path: path} = repo) when is_binary(path) do
with {:ok, sha} <- Git.resolve(path, repo.default_branch || "main") do
Git.count_commits(path, sha)
end
end
defp head_commit_count(_repo), do: {:error, :no_repository}
# ── release, mirror, language, ci duration ────────────────────────────
@doc """
The newest of `repo`'s tags: the highest version among tags that parse
as one (`v1.4.2` or `1.4.2`, releases before pre-releases), else the
last in natural order (`build-10` after `build-9`). `nil` with no tags.
"""
@spec latest_tag(Repository.t()) :: String.t() | nil
def latest_tag(%Repository{disk_path: path}) when is_binary(path) do
case Git.list_refs(path) do
{:ok, refs} -> pick_tag(for %{name: "refs/tags/" <> tag} <- refs, do: tag)
_ -> nil
end
end
def latest_tag(_repo), do: nil
@doc false
def pick_tag([]), do: nil
def pick_tag(tags) do
versions =
for tag <- tags,
{:ok, version} <- [Version.parse(String.trim_leading(tag, "v"))],
do: {version, tag}
{releases, pre} = Enum.split_with(versions, fn {v, _} -> v.pre == [] end)
case {releases, pre} do
{[_ | _], _} -> releases |> Enum.max_by(&elem(&1, 0), Version) |> elem(1)
{[], [_ | _]} -> pre |> Enum.max_by(&elem(&1, 0), Version) |> elem(1)
{[], []} -> Enum.max_by(tags, &natural_key/1)
end
end
defp natural_key(tag) do
~r/\d+/
|> Regex.split(tag, include_captures: true, trim: true)
|> Enum.map(fn part ->
case Integer.parse(part) do
{n, ""} -> {0, n}
_ -> {1, part}
end
end)
end
@doc "The `mirror` badge for `repo` as of `now`."
def mirror(%Repository{upstream_url: nil}, _now),
do: %{label: "mirror", value: "not a mirror", slot: "muted"}
def mirror(%Repository{upstream_sync_error: error}, _now) when is_binary(error),
do: %{label: "mirror", value: "sync failing", slot: "error"}
def mirror(%Repository{upstream_synced_at: nil}, _now),
do: %{label: "mirror", value: "never synced", slot: "warning"}
def mirror(%Repository{upstream_synced_at: at}, now),
do: mirror_age(max(DateTime.diff(now, at, :second), 0))
defp mirror_age(seconds) do
slot =
cond do
seconds < 2 * 86_400 -> "success"
seconds < 14 * 86_400 -> "warning"
true -> "error"
end
%{label: "mirror", value: "synced #{ago(seconds)}", slot: slot}
end
defp ago(s) when s < 3600, do: "#{max(div(s, 60), 1)}m ago"
defp ago(s) when s < 2 * 86_400, do: "#{div(s, 3600)}h ago"
defp ago(s), do: "#{div(s, 86_400)}d ago"
# The newest computed language stats, rather than the default branch
# tip's: a badge shouldn't queue a stats job on every fetch, and a
# push since the last run moves the share by a rounding error.
defp top_language(repo) do
row =
from(l in GitCommitLanguageStats,
where: l.repository_id == ^repo.id,
order_by: [desc: l.computed_at],
limit: 1
)
|> Repo.one()
case row do
%{languages: %{"items" => [%{"language" => lang} = top | _]}, total_bytes: total}
when is_binary(lang) and is_integer(total) and total > 0 ->
{lang, (top["bytes"] || 0) * 100 / total}
_ ->
nil
end
end
defp language_badge(language, percent) do
%{
label: "language",
value:
"#{String.slice(language, 0, 20)} #{:erlang.float_to_binary(percent * 1.0, decimals: 0)}%",
slot: "info"
}
end
# Seconds the newest push whose runs have all finished took, from its
# first run starting to its last finishing. A push still running is
# skipped for the one before it: the badge shows a duration, not a
# stopwatch.
defp last_push_duration(repo) do
ref = branch_ref(repo)
from(r in WorkflowRun,
where:
r.repository_id == ^repo.id and r.head_ref == ^ref and r.trigger == "push" and
is_nil(r.pull_request_number),
order_by: [desc: r.inserted_at, desc: r.id],
limit: 50
)
|> Repo.all()
|> Enum.chunk_by(& &1.event_id)
|> Enum.find(fn runs -> Enum.all?(runs, & &1.completed_at) end)
|> case do
nil ->
nil
runs ->
start = runs |> Enum.map(&(&1.started_at || &1.inserted_at)) |> Enum.min(DateTime)
finish = runs |> Enum.map(& &1.completed_at) |> Enum.max(DateTime)
max(DateTime.diff(finish, start, :second), 0)
end
end
@doc "`252` seconds as `4m 12s`."
def duration(s) when s < 60, do: "#{s}s"
def duration(s) when s < 3600, do: "#{div(s, 60)}m #{rem(s, 60)}s"
def duration(s), do: "#{div(s, 3600)}h #{div(rem(s, 3600), 60)}m"
# ── workflows ─────────────────────────────────────────────────────────
@summary_output "gitgud-summary"
@summary_max 40
# Worst first. A job's status, to the badge's state.
@job_states [
{"failure", :failing},
{"warning", :warning},
{"running", :running},
{"queued", :queued},
{"blocked", :queued},
{"waiting", :queued},
{"awaiting_approval", :queued},
{"cancelled", :cancelled},
{"success", :passing},
{"skipped", :passing}
]
@state_rank @job_states
|> Enum.map(&elem(&1, 1))
|> Enum.uniq()
|> Enum.with_index()
|> Map.new()
@doc """
The `workflow/<name>` badge: the newest run of the workflow called
`name` on `repo`'s default branch, from anything but a pull request.
"""
@spec workflow(Repository.t(), String.t()) :: Svg.content()
def workflow(%Repository{} = repo, name) do
case latest_workflow_run(repo, name) do
nil ->
%{label: label(name), value: "none", slot: "muted"}
run ->
jobs = Repo.all(from j in WorkflowJob, where: j.workflow_run_id == ^run.id)
{state, summaries} = workflow_state(run, jobs)
workflow_badge(name, state, summaries)
end
end
defp workflow_state(run, []), do: {job_state(run.status), []}
defp workflow_state(_run, jobs) do
states = Enum.map(jobs, &{job_state(&1.status), &1})
worst = states |> Enum.map(&elem(&1, 0)) |> Enum.min_by(&Map.fetch!(@state_rank, &1))
summaries =
for {^worst, job} <- states,
worst != :passing,
summary = job_summary(job),
do: summary
{worst, Enum.uniq(summaries)}
end
defp job_state(status) do
Enum.find_value(@job_states, :queued, fn {s, state} -> if s == status, do: state end)
end
defp workflow_badge(name, state, summaries) do
{word, slot} =
case state do
:failing -> {"failing", "error"}
:warning -> {"warning", "warning"}
:running -> {"running", "info"}
:queued -> {"queued", "warning"}
:cancelled -> {"cancelled", "muted"}
:passing -> {"passing", "success"}
end
value =
case summaries do
[] -> word
_ -> summaries |> Enum.join(", ") |> String.slice(0, @summary_max)
end
%{label: label(name), value: value, slot: slot}
end
defp label(name), do: String.slice(name, 0, 30)
@doc """
What a finished job said it found, through its `gitgud-summary`
output: trimmed, one line, at most #{@summary_max} characters. `nil`
when it said nothing.
"""
@spec job_summary(WorkflowJob.t()) :: String.t() | nil
def job_summary(%WorkflowJob{outputs: %{@summary_output => value}}) when is_binary(value) do
value
|> String.replace(~r/[[:cntrl:]]+/u, " ")
|> String.trim()
|> String.slice(0, @summary_max)
|> case do
"" -> nil
summary -> summary
end
end
def job_summary(_job), do: nil
@doc """
The workflows that have run on `repo`'s default branch lately, by the
name their badge takes, for the badge sheet.
"""
@spec workflow_names(Repository.t()) :: [String.t()]
def workflow_names(%Repository{} = repo) do
ref = branch_ref(repo)
from(r in WorkflowRun,
where:
r.repository_id == ^repo.id and r.head_ref == ^ref and is_nil(r.pull_request_number) and
r.trigger != "pull_request",
order_by: [desc: r.inserted_at],
limit: 200,
select: {r.workflow_name, r.workflow_path}
)
|> Repo.all()
|> Enum.map(&run_name/1)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> Enum.sort()
end
defp run_name({name, _path}) when is_binary(name) and name != "", do: name
defp run_name({_name, path}) when is_binary(path),
do: path |> Path.basename() |> Path.rootname()
defp run_name(_), do: nil
defp latest_workflow_run(repo, name) do
ref = branch_ref(repo)
files = [name <> ".yml", name <> ".yaml"]
from(r in WorkflowRun,
where:
r.repository_id == ^repo.id and r.head_ref == ^ref and is_nil(r.pull_request_number) and
r.trigger != "pull_request",
where:
r.workflow_name == ^name or
(is_nil(r.workflow_name) and
fragment("regexp_replace(?, '^.*/', '')", r.workflow_path) in ^files),
order_by: [desc: r.inserted_at, desc: r.id],
limit: 1
)
|> Repo.one()
end
# ── data ──────────────────────────────────────────────────────────────
defp branch_ref(repo), do: "refs/heads/" <> (repo.default_branch || "main")
defp head_commit(%Repository{disk_path: path} = repo) when is_binary(path) do
with {:ok, sha} <- Git.resolve(path, repo.default_branch || "main") do
Git.commit(path, sha)
end
end
defp head_commit(_repo), do: {:error, :no_repository}
defp ci_value(repo) do
ref = branch_ref(repo)
latest =
from(r in WorkflowRun,
where:
r.repository_id == ^repo.id and r.head_ref == ^ref and r.trigger == "push" and
is_nil(r.pull_request_number),
order_by: [desc: r.inserted_at, desc: r.id],
limit: 1
)
|> Repo.one()
case latest do
nil -> "none"
run -> run |> event_runs() |> Enum.map(& &1.status) |> ci_state()
end
end
defp event_runs(%WorkflowRun{event_id: nil} = run), do: [run]
defp event_runs(%WorkflowRun{event_id: event_id, repository_id: rid}) do
Repo.all(from r in WorkflowRun, where: r.repository_id == ^rid and r.event_id == ^event_id)
end
defp ci_state(statuses) do
cond do
"failure" in statuses -> "failing"
"running" in statuses -> "running"
"queued" in statuses -> "queued"
Enum.all?(statuses, &(&1 == "cancelled")) -> "cancelled"
true -> "passing"
end
end
defp latest_coverage(repo) do
ref = branch_ref(repo)
shas =
from(r in WorkflowRun,
where: r.repository_id == ^repo.id and r.head_ref == ^ref,
select: r.head_sha
)
from(c in CommitStatus,
where:
c.repository_id == ^repo.id and c.context == @coverage_context and
c.sha in subquery(shas),
order_by: [desc: c.updated_at, desc: c.id],
limit: 1,
select: c.description
)
|> Repo.one()
|> parse_percent()
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
24.2 KiB · text
History
0d6bdf0