# 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/<(?.*?)>;\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: # ```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("""
""") 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