Powered by AppSignal & Oban Pro

Github Stars

github_stars.livemd

Github Stars

Mix.install([
  {:kino, "~> 0.18.0"},
  {:req, "~> 0.5.17"},
  {:kino_vega_lite, "~> 0.1.13"}
])

Pegando dados da API GitHub

Primeiro, vamos criar um cliente simples da API do GitHub. Ele será usado para buscar a lista de pessoas que deram estrela em um repositório.

Vamos dividir esse cliente de API em dois módulos.

O primeiro ficará responsável por buscar a lista de stargazers de um repositório específico.

defmodule GitHubApi do
  def stargazers(repo_name) do
    stargazers_path = "/repos/#{repo_name}/stargazers?per_page=100"

    with {:ok, response} <- request(stargazers_path),
         {:ok, responses} <- GitHubApi.Paginator.maybe_paginate(response) do
      responses
      |> Enum.flat_map(fn response -> parse_stargazers(response.body) end)
      |> then(fn stargazers -> {:ok, stargazers} end)
    end
  end

  def stargazers!(repo_name) do
    {:ok, stargazers} = stargazers(repo_name)
    stargazers
  end

  def request(path) do
    case Req.get(new(), url: path) do
      {:ok, %Req.Response{status: 200} = response} -> {:ok, response}
      {:ok, %Req.Response{status: 403}} -> {:error, "GitHub API rate limit reached"}
      {:ok, response} -> {:error, response.body["message"]}
      {:error, exception} -> {:error, "Exception calling GitHub API: #{inspect(exception)}"}
    end
  end

  def new do
    req =
      Req.new(
        base_url: "https://api.github.com",
        headers: [
          accept: "application/vnd.github.star+json",
          "X-GitHub-Api-Version": "2022-11-28"
        ]
      )

    req =
      case System.fetch_env("LB_GITHUB_TOKEN") do
        {:ok, token} -> Req.merge(req, auth: {:bearer, token})
        _ -> req
      end

    req
  end

  defp parse_stargazers(stargazers) do
    Enum.map(stargazers, fn stargazer ->
      %{"starred_at" => starred_at, "user" => %{"login" => user_login}} = stargazer
      {:ok, starred_at, _} = DateTime.from_iso8601(starred_at)

      %{
        starred_at: starred_at,
        user_login: user_login
      }
    end)
  end
end
defmodule GitHubApi.Paginator do
  def maybe_paginate(response) do
    responses =
      if "link" in Map.keys(response.headers) do
        paginate(response)
      else
        [response]
      end

    {:ok, responses}
  end

  def paginate(response) do
    pageless_endpoint = pageless_endpoint(response.headers["link"])
    next_page = page_number(response.headers["link"], "next")
    last_page = page_number(response.headers["link"], "last")

    additional_responses =
      Task.async_stream(
        next_page..last_page,
        fn page -> GitHubApi.request(pageless_endpoint <> "&page=#{page}") end,
        max_concurrency: 30
      )
      |> Enum.flat_map(fn
        {:ok, {:ok, response}} -> [response]
        _ -> []
      end)

    [response] ++ additional_responses
  end

  defp pageless_endpoint(link_header) do
    links = hd(link_header)

    %{"endpoint" => endpoint} = Regex.named_captures(~r/<(?.*?)>;\s/, links)
    uri = URI.parse(endpoint)

    %{path: path} = Map.take(uri, [:path])

    pageless_query =
      URI.decode_query(uri.query)
      |> Map.drop(["page"])
      |> URI.encode_query()

    "#{path}?#{pageless_query}"
  end

  defp page_number(link_header, rel) do
    links = hd(link_header)

    %{"page_number" => page_number} =
      Regex.named_captures(~r/<.*page=(?\d+)>; rel="#{rel}"/, links)

    String.to_integer(page_number)
  end
end
repo_name = "livebook-dev/vega_lite"

stargazers =
  case GitHubApi.stargazers(repo_name) do
    {:ok, stargazers} ->
      stargazers

    {:error, error_message} ->
      IO.puts(error_message)
      []
  end

Processando os dados do GitHub

Agora precisamos transformar os dados de stargazers para um formato que funcione para visualização. Vamos organizá-los para mostrar o total acumulado de estrelas por data, assim:

%{
  date: [~D[2025-04-25], ~D[2025-04-24], ~D[2025-04-23]],
  stars: [528, 510, 490]
}

O módulo abaixo vai transformar os dados do jeito que a gente precisa.

defmodule GithubDataProcessor do
  def cumulative_star_dates(stargazers) do
    stargazers
    |> Enum.group_by(&amp;DateTime.to_date(&amp;1.starred_at))
    |> Enum.map(fn {date, stargazers} -> {date, Enum.count(stargazers)} end)
    |> List.keysort(0, {:asc, Date})
    |> Enum.reduce(%{date: [], stars: [0]}, fn {date, stars}, acc ->
      %{date: dates_acc, stars: stars_acc} = acc

      cumulative_stars = List.first(stars_acc) + stars

      %{date: [date | dates_acc], stars: [cumulative_stars | stars_acc]}
    end)
  end
end
data = GithubDataProcessor.cumulative_star_dates(stargazers)
# |> Kino.Tree.new

Componentes da UI (interface com o usuário)

defmodule StarsChart do
  def new(data) do
    VegaLite.new(width: 700, height: 450, title: "GitHub Stars history")
    |> VegaLite.data_from_values(data, only: ["date", "stars"])
    |> VegaLite.mark(:line, tooltip: true)
    |> VegaLite.encode_field(:x, "date", type: :temporal)
    |> VegaLite.encode_field(:y, "stars", type: :quantitative)
  end
end

UI (interface com o usuário)

form =
  Kino.Control.form(
    [
      name: Kino.Input.text("Github full repo name", default: "livebook-dev/kino")
    ],
    submit: "Submit"
  )

output_frame = Kino.Frame.new()
layout_frame = Kino.Layout.grid([form, output_frame])

Kino.listen(form, fn event ->
  Kino.Frame.render(output_frame, "Getting data from Github...")
  %{data: %{name: repo_name}} = event

  data =
    repo_name
    |> GitHubApi.stargazers!()
    |> GithubDataProcessor.cumulative_star_dates()

  table = Kino.DataTable.new(data)
  chart = StarsChart.new(data)
  tabs = Kino.Layout.tabs(Chart: chart, Table: table)
  Kino.Frame.render(output_frame, tabs)
end)

layout_frame