diff --git a/lib/livebook/notebook/learn.ex b/lib/livebook/notebook/learn.ex index 471a63124..26b1d76af 100644 --- a/lib/livebook/notebook/learn.ex +++ b/lib/livebook/notebook/learn.ex @@ -83,6 +83,13 @@ defmodule Livebook.Notebook.Learn do cover_filename: "maplibre.png" } }, + %{ + path: Path.join(__DIR__, "learn/github_stars.livemd"), + details: %{ + description: "Build a Livebook app to visualize GitHub repository star growth over time.", + cover_filename: "github-stars.png" + } + }, %{ ref: :kino_intro, path: Path.join(__DIR__, "learn/kino/intro_to_kino.livemd") diff --git a/lib/livebook/notebook/learn/github_stars.livemd b/lib/livebook/notebook/learn/github_stars.livemd new file mode 100644 index 000000000..212e42a30 --- /dev/null +++ b/lib/livebook/notebook/learn/github_stars.livemd @@ -0,0 +1,356 @@ + + +# Counting GitHub Stars + +```elixir +Mix.install([ + {:kino, "~> 0.15.3"}, + {: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. + +```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/<(?.*?)>;\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. + +```elixir +repo_name = "livebook-dev/livebook" +{:ok, stargazers} = GitHubApi.stargazers(repo_name) +``` + +## 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. + +```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(""" +
+ + + """) + 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 (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 diff --git a/static/images/github-stars.png b/static/images/github-stars.png new file mode 100644 index 000000000..2ca83c4c6 Binary files /dev/null and b/static/images/github-stars.png differ