<!-- livebook:{"app_settings":{"access_type":"public","output_type":"rich","show_source":true,"slug":"github-stars"}} -->

# Counting GitHub Stars

```elixir
Mix.install([
  {:kino, "~> 0.19.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.

<!-- livebook:{"break_markdown":true} -->

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.

```elixir
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.

```elixir
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/<(?<endpoint>.*?)>;\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=(?<page_number>\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.

```elixir
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:

<!-- livebook:{"force_markdown":true} -->

```elixir
%{
  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.

```elixir
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
```

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

```elixir
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.

```elixir
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:

```elixir
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:

```elixir
# 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.

```elixir
defmodule KinoSpinner do
  def new(dimensions \\ "30px") do
    Kino.HTML.new("""
    <div class="loader"></div>

    <style>
      .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); }
      }
    </style>
    """)
  end
end
```

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

```elixir
# 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

```elixir
# 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

```elixir
# 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 <span style="font-weight: 600"><i class="ri-livebook-deploy"></i> (app settings)</span> icon on the sidebar
2. Click on the **"Launch preview"** button to run your notebook as an app
3. Click on the <span style="font-weight: 600"><i class="ri-link"></i> (open)</span> 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
