Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Github Stars

elixir-conf-2024/github_stars.livemd

Github Stars

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

Github data

defmodule GithubApi do
  def stargazers(repo_name) do
    case Req.get!(base_req(), url: "/repos/#{repo_name}/stargazers?per_page=100") do
      %Req.Response{status: 200, headers: headers} ->
        last_page = get_last_page_number(headers)
        star_dates = concurret_paginate(repo_name, last_page)
        {:ok, star_dates}

      %Req.Response{status: 404, body: body} ->
        {:error, body["message"]}
    end
  end

  defp base_req() do
    Req.new(
      base_url: "https://api.github.com",
      auth: {:bearer, github_token()},
      headers: [
        accept: "application/vnd.github.star+json",
        "X-GitHub-Api-Version": "2022-11-28"
      ]
    )
  end

  defp github_token do
    System.fetch_env!("LB_GITHUB_TOKEN_DEMO")
  end

  defp get_last_page_number(headers) do
    link_header =
      headers
      |> Enum.find(fn {key, _value} -> key == "link" end)
      |> elem(1)
      |> List.first()

    last_link =
      link_header
      |> String.split(",")
      |> Enum.map(fn link ->
        [url, rel] = String.split(link, ";")
        [url] = Regex.run(~r/<(.*)>/, url, capture: :all_but_first)
        [_, rel] = String.split(rel, "=")
        rel = String.trim(rel)
        [url, rel]
      end)
      |> Enum.find(fn [_url, rel] -> rel == "\"last\"" end)

    if last_link == nil do
      ""
    else
      [page, _] = last_link

      %{"page_number" => page_number} =
        Regex.named_captures(~r/.*&page=(?\d+)/, page)

      String.to_integer(page_number)
    end
  end

  defp concurret_paginate(repo_name, last_page) do
    1..last_page
    |> Task.async_stream(
      fn page ->
        response =
          Req.get!(base_req(), url: "/repos/#{repo_name}/stargazers?per_page=100&page=#{page}")

        if response.status != 200, do: IO.inspect("BAM!")
        parse(response.body)
      end,
      max_concurrency: 60
    )
    |> Enum.reduce([], fn {:ok, stargazers}, stargazers_acc ->
      [stargazers | stargazers_acc]
    end)
    |> List.flatten()
  end

  defp parse(body) do
    body
    |> Enum.map(fn %{"starred_at" => starred_at, "user" => %{"login" => user_login}} ->
      {:ok, starred_at, _} = DateTime.from_iso8601(starred_at)
      %{
        starred_at: starred_at,
        user_login: user_login
      }
    end)
  end
end
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: []}, fn {date, stars}, data ->
      %{date: dates_acc, stars: stars_acc} = data

      cumulative_stars =
        if List.first(stars_acc) == nil do
          0 + stars
        else
          List.first(stars_acc) + stars
        end

      %{date: [date | dates_acc], stars: [cumulative_stars | stars_acc]}
    end)
  end
end

UI

defmodule StarsChart do
  def new(data) do
    VegaLite.new(width: 851, height: 549, title: "Github Stars history")
    |> VegaLite.data_from_values(data, only: ["date", "stars"])
    |> VegaLite.mark(:line)
    |> VegaLite.encode_field(:x, "date", type: :temporal)
    |> VegaLite.encode_field(:y, "stars", type: :quantitative)
  end
end
form =
  Kino.Control.form(
    [
      name: Kino.Input.text("Github full repo name", default: "livebook-dev/livebook")
    ],
    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()
    |> then(fn {:ok, star_dates} -> GithubDataProcessor.cumulative_star_dates(star_dates) end)

  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