# Run as: iex --dot-iex path/to/notebook.exs
# Title: Counting GitHub Stars
Mix.install([
{:kino, "~> 0.18.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/<(?<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.
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:
# ```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.
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("""
<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.
# 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 <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