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

Counting GitHub Stars

github_stars.livemd

Counting GitHub Stars

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

Introduction

In this tutorial, we’ll build a simple Livebook app together.

We’ll create an app with a form where we can enter a GitHub repository name, and it will show us how the repository’s stars have grown over time - displayed both as a chart and as a table.

Let’s get started.

GitHub API client

First, we’ll build a simple API client for GitHub. We need this to fetch the list of people who have starred a repository.

We’ll break our API client in two modules.

The first one will be responsible for retrieving a list of stargazers for a given repository.

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 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.new(
      base_url: "https://api.github.com",
      headers: [
        accept: "application/vnd.github.star+json",
        "X-GitHub-Api-Version": "2022-11-28"
      ]
    )
  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

This second module will handle the pagination of GitHub API responses.

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

Let’s try our client.

Execute the cell below. You should see a list of GitHub users who starred the repository.

repo_name = "livebook-dev/vega_lite"

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

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

Data processing

Now we need to transform our stargazers data into a format that works for visualization. We’ll shape it to show cumulative star counts by date, like this:

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

The module below will transform the data to the way we need.

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

Execute the cell below. You should see a data structure with dates and corresponding star counts.

data = GithubDataProcessor.cumulative_star_dates(stargazers)

Creating mock data

GitHub has API rate limits that we might hit. Let’s prepare some mock data so our app will work even if the API is unavailable.

mock_data = %{
  date: [
    ~D[2025-04-25], ~D[2025-04-24], ~D[2025-04-23], ~D[2025-04-22], ~D[2025-04-21],
    ~D[2025-04-20], ~D[2025-04-19], ~D[2025-04-18], ~D[2025-04-17], ~D[2025-04-16],
    ~D[2025-04-15], ~D[2025-04-14], ~D[2025-04-13], ~D[2025-04-12], ~D[2025-04-11],
    ~D[2025-04-10], ~D[2025-04-09], ~D[2025-04-08], ~D[2025-04-07], ~D[2025-04-06],
    ~D[2025-04-05], ~D[2025-04-04], ~D[2025-04-03], ~D[2025-04-02], ~D[2025-04-01],
    ~D[2025-03-31], ~D[2025-03-30], ~D[2025-03-29], ~D[2025-03-28], ~D[2025-03-27]
  ],
  stars: [
    528, 510, 490, 465, 435,
    410, 380, 350, 320, 290,
    260, 230, 200, 175, 150,
    130, 110, 90,  75,  60,
    48,  38,  30,  22,  15,
    10,  6,   3,   1,   0]
}

Building the UI components

Now let’s build the visual parts of our app.

First, we’ll create a chart component to plot how stars increase over time:

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

Uncomment the code in the cell below and execute it to see the chart in action. You should see a line graph showing star growth over time:

# StarsChart.new(data)

Now, we’ll build a loading spinner. This will give users visual feedback while they wait for data to load from GitHub.

To build this component, we’ll write some custom HTML and CSS, and use Kino.HTML to render it.

defmodule KinoSpinner do
  def new(dimensions \\ "30px") do
    Kino.HTML.new("""
    

    
      .loader {
        border: 16px solid #f3f3f3; /* Light grey */
        border-top: 16px solid #3498db; /* Blue */
        border-radius: 50%;
        width: #{dimensions};
        height: #{dimensions};
        animation: spin 2s linear infinite;
      }

      @keyframes spin {
        0% { transform: rotate(0deg); }
        100% { transform: rotate(360deg); }
      }
    
    """)
  end
end

Uncomment the code in the cell below, and execute it to see our spinner working.

# KinoSpinner.new()

Building the app UI

Now we’ll create the interactive form where users can enter a GitHub repository name.

  1. We’ll use Kino.Control.form to build a form
  2. We’ll create a Kino.Frame, where we’ll put the result of submitting the form
  3. We’ll create a simple grid layout with two rows:
    • the form
    • the output frame
# 1. A form for users to enter a GitHub repository name
form =
  Kino.Control.form(
    [
      repo_name: Kino.Input.text("GitHub repo", default: "livebook-dev/kino")
    ],
    submit: "Submit"
  )

# 2. A Kino frame to display the results
output_frame = Kino.Frame.new()

# 3. A grid layout containing the form and the output frame
layout_frame = Kino.Layout.grid([form, output_frame], boxed: true)

Now, we’ll make our form interactive.

  1. We’ll use Kino.listen to listen to form submission events
  2. We’ll display a spinner while we wait for the response from the API
  3. Read data from the form submission
  4. Fetch data from GitHub’s API
  5. Show the data in two tabs, as a chart and as a table
  6. Handle errors, falling back to mock data when needed
# 1. Listen for form submissions
Kino.listen(form, fn form_submission ->
  # 2. Show spinner
  waiting_feedback = Kino.Layout.grid([Kino.Text.new("Getting data from GitHub..."), KinoSpinner.new()])
  Kino.Frame.render(output_frame, waiting_feedback)

  # 3. Read data from form submission
  %{data: %{repo_name: repo_name}} = form_submission

  # 4. Fetch data from GitHub's API
  case GitHubApi.stargazers(repo_name) do
    {:ok, star_dates} ->
      data = GithubDataProcessor.cumulative_star_dates(star_dates)

      # 5. Show the data in two tabs, as a chart and as a table
      table = Kino.DataTable.new(data)
      chart = StarsChart.new(data)
      tabs = Kino.Layout.tabs(Chart: chart, Table: table)
      Kino.Frame.render(output_frame, tabs)

    {:error, error_message} ->
      # 6. Handle errors, falling back to mock data when needed
      Kino.Frame.render(output_frame, "Error contacting GitHub API: #{error_message}")
      Kino.Frame.append(output_frame, "We're going to use mock data for demo purposes")

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

Execute the cell above. Now the form should be fully functioning. Go back to the form, and give it a try.

Running as a Livebook app

Up until this moment, we’ve been interacting with our code as a notebook. But the goal of this tutorial is to actually build a Livebook app. So, let’s run our notebook as a Livebook app.

  1. Click on the (app settings) icon on the sidebar
  2. Click on the “Launch preview” button to run your notebook as an app
  3. Click on the (open) icon inside the sidebar panel to open your app

What we’ve built

In this tutorial, we’ve created a GitHub stars Livebook app that:

  • Connects to the GitHub API
  • Processes time-series data
  • Creates interactive visualizations