Run this notebook

Use Livebook to open this notebook and explore new ideas.

It is easy to get started, on your machine or the cloud.

Click below to open and run it in your Livebook at .

(or change your Livebook location)

<!-- livebook:{"app_settings":{"access_type":"public","output_type":"rich","show_source":true,"slug":"github-stars"},"deployment_group_id":"199","hub_id":"team-demos"} --> # Counting GitHub Stars ```elixir Mix.install([ {:kino, "~> 0.16.0"}, {:req, "~> 0.5.10"}, {:kino_vega_lite, "~> 0.1.13"} ]) ``` ## GitHub API client 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", auth: {:bearer, System.fetch_env!("LB_GITHUB_TOKEN_DEMO")}, 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: 5 ) |> 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 ``` ## Data processing 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 ``` ## Creating mock data (for demo) 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 ``` 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 ``` ## 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: "tidewave-ai/tidewave_phoenix") ], 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 <!-- livebook:{"offset":9379,"stamp":{"token":"XCP.mNAI_X9fcwLKwD1_gxb2k_3Qo_KLewWdeknediSE4uP9Ur80PTHFOXeV5XOdDOaGx1P4e33MNJO-LmS-Z-PVHZsfS8hP_IawfQTdkjeabhLISJeDrbjVXfEMesY","token_signature":"T7gj2ZEbVbhtUDLm3zdbMMjMGJibYjXP25Dvh4caE0USPURPKN2RRb9EYwmStuPSXrL5KBNx1zc2OSYBySg_QWTGYkP2x4JQAztUQukyml6crRV9CuUOdYbyMZt6yUT8kUiWm5OMLBjY32dG3xpUwZ1yrauvYEkUX74edqGDaN3lYiCn_lo7ubNlqsVqqCYnuaea2JZ6_zp4_dtvM4idzvX2EwtkjwK1HZVZPn8em-QMuLpcGCIktfLaB2q1JQu2Wo5EOdjpR6MzFLJ89nKjcXn18mqus_PZbYz838jwU1c2P21zHwk1Tf4UXXR0Ggtgu20UqkbDhioxpIyDxHGzgw","version":1}} -->
See source

Have you already installed Livebook?

If you already installed Livebook, you can configure the default Livebook location where you want to open notebooks.
Livebook up Checking status We can't reach this Livebook (but we saved your preference anyway)
Run notebook

Not yet? Install Livebook in just a minute

Livebook is open source, free, and ready to run anywhere.

Run in the cloud

on select platforms

To run on Linux, Docker, embedded devices, or Elixir’s Mix, check our README.

PLATINUM SPONSORS
SPONSORS
Code navigation with go to definition of modules and functions Read More