24.8 KiB · text History a0aa462
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 """
  Whether `repo` follows an upstream: one it syncs from on a schedule,
  or has synced from, or tried to. A repository imported once from
  another forge also keeps its `upstream_url`, but having never synced
  and not being set to, it's developed here and isn't a mirror.
  """
  @spec mirror?(Repository.t()) :: boolean()
  def mirror?(%Repository{upstream_url: nil}), do: false

  def mirror?(%Repository{} = repo) do
    repo.upstream_auto_sync == true or not is_nil(repo.upstream_synced_at) or
      not is_nil(repo.upstream_sync_error)
  end

  @doc "The `mirror` badge for `repo` as of `now`."
  def mirror(%Repository{} = repo, now) do
    if mirror?(repo), do: mirror_state(repo, now), else: not_a_mirror()
  end

  defp not_a_mirror, do: %{label: "mirror", value: "not a mirror", slot: "muted"}

  defp mirror_state(%Repository{upstream_sync_error: error}, _now) when is_binary(error),
    do: %{label: "mirror", value: "sync failing", slot: "error"}

  defp mirror_state(%Repository{upstream_synced_at: nil}, _now),
    do: %{label: "mirror", value: "never synced", slot: "warning"}

  defp mirror_state(%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