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(&DateTime.to_date(&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