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(&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.
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.
-
We’ll use
Kino.Control.form
to build a form -
We’ll create a
Kino.Frame
, where we’ll put the result of submitting the form -
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.
-
We’ll use
Kino.listen
to listen to form submission events - We’ll display a spinner while we wait for the response from the API
- Read data from the form submission
- Fetch data from GitHub’s API
- Show the data in two tabs, as a chart and as a table
- 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.
- Click on the (app settings) icon on the sidebar
- Click on the “Launch preview” button to run your notebook as an app
- 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