livebook / public-apps /github_stars.livemd
hugobarauna's picture
Change github stars deploy config to never shutdown
a6565fd
raw
history blame
6.55 kB
<!-- livebook:{"app_settings":{"access_type":"public","output_type":"rich","show_source":true,"slug":"github-stars"}} -->
# Github Repo Star History
```elixir
Mix.install([
{:req, "~> 0.3.9"},
{:kino, github: "livebook-dev/kino", override: true},
{:kino_vega_lite, "~> 0.1.7"},
{:kino_explorer, "~> 0.1.7"}
])
```
## About this notebook
```elixir
Kino.Markdown.new("""
## What is this?
This is a simple example of a [Livebook](https://livebook.dev/?utm_source=github-stars-app&utm_medium=livebook-apps) app that connects to an API, gets some data, and displays that data in a chart.
It uses Github's API to get data about the number of stars of repo had over time and plots that data.
### What are Livebook apps?
A Livebook app is a notebook-based app written in Elixir and using [Livebook](https://livebook.dev/?utm_source=github-stars-app&utm_medium=livebook-apps).
If you want to know more about Livebook apps, read or watch that [blog post](https://news.livebook.dev/deploy-notebooks-as-apps-quality-of-life-upgrades---launch-week-1---day-1-2OTEWI) or [video](https://www.youtube.com/watch?v=q7T6ue7cw1Q).
No HTML or Javascript was written for that app, only Elixir. 😉
You can view the source code by clicking the icon at the top corner.
""")
```
## Github API client
This notebook uses GitHub API to retrieve repository metadata. It needs a personal access token acess the API, otherwise it would easily rit the rate limit for unauthenticated requests.
Here's the URL to generate a Github API token: https://github.com/settings/tokens/new
```elixir
defmodule Github do
def get_star_dates(repo_name) do
case Req.get!(base_req(), url: "/repos/#{repo_name}/stargazers?per_page=100") do
%Req.Response{status: 200, headers: headers} ->
last_page = get_last_page_number(headers)
star_dates = concurret_paginate(repo_name, last_page)
{:ok, star_dates}
%Req.Response{status: 404, body: body} ->
{:error, body["message"]}
end
end
defp base_req() do
Req.new(
base_url: "https://api.github.com",
auth: {:bearer, github_token()},
headers: [
accept: "application/vnd.github.star+json",
"X-GitHub-Api-Version": "2022-11-28"
]
)
end
defp github_token do
System.fetch_env!("LB_GITHUB_TOKEN")
end
defp get_last_page_number(headers) do
link_header =
headers
|> Enum.find(fn {key, _value} -> key == "link" end)
|> elem(1)
last_link =
link_header
|> String.split(",")
|> Enum.map(fn link ->
[url, rel] = String.split(link, ";")
[url] = Regex.run(~r/<(.*)>/, url, capture: :all_but_first)
[_, rel] = String.split(rel, "=")
rel = String.trim(rel)
[url, rel]
end)
|> Enum.find(fn [_url, rel] -> rel == "\"last\"" end)
if last_link == nil do
""
else
[page, _] = last_link
%{"page_number" => page_number} =
Regex.named_captures(~r/.*&page=(?<page_number>\d+)/, page)
String.to_integer(page_number)
end
end
defp concurret_paginate(repo_name, last_page) do
1..last_page
|> Task.async_stream(
fn page ->
response =
Req.get!(base_req(), url: "/repos/#{repo_name}/stargazers?per_page=100&page=#{page}")
if response.status != 200, do: IO.inspect("BAM!")
parse_star_dates(response.body)
end,
max_concurrency: 60
)
|> Enum.reduce([], fn {:ok, star_dates}, star_dates_acc ->
[star_dates | star_dates_acc]
end)
|> List.flatten()
end
defp parse_star_dates(body) do
body
|> Enum.map(fn %{"starred_at" => date} ->
{:ok, datetime, _} = DateTime.from_iso8601(date)
DateTime.to_date(datetime)
end)
end
end
```
## Data processing
```elixir
process_data = fn star_dates ->
star_dates
|> List.flatten()
|> Enum.group_by(& &1)
|> Enum.map(fn {date, dates} -> {date, Enum.count(dates)} end)
|> List.keysort(0, {:asc, Date})
|> Enum.reduce(%{date: [], stars: []}, fn {date, stars}, data ->
%{date: dates_acc, stars: stars_acc} = data
cumulative_stars =
if List.first(stars_acc) == nil do
0 + stars
else
List.first(stars_acc) + stars
end
%{date: [date | dates_acc], stars: [cumulative_stars | stars_acc]}
end)
end
```
## Chart generation
```elixir
generate_chart = fn data, repo_name ->
VegaLite.new(
width: 850,
height: 550,
title: [text: "⭐️ Github Stars History for #{repo_name}", font_size: 20]
)
|> VegaLite.data_from_values(data, only: ["date", "stars"])
|> VegaLite.mark(:line, tooltip: true)
|> VegaLite.encode_field(:x, "date",
type: :temporal,
axis: [
label_expr:
"[timeFormat(datum.value, '%b'), timeFormat(datum.value, '%m') == '01' ? timeFormat(datum.value, '%Y') : ['']]",
grid_dash: [
condition: [test: [field: "value", time_unit: "month", equal: 1], value: []],
value: [2, 2]
],
tick_dash: [
condition: [test: [field: "value", time_unit: "month", equal: 1], value: []],
value: [2, 2]
]
]
)
|> VegaLite.encode_field(:y, "stars", type: :quantitative)
end
```
## UI
```elixir
display_data = fn frame, %{data: data, repo_name: repo_name}, to: origin ->
chart = generate_chart.(data, repo_name)
Kino.Frame.render(
frame,
Kino.Layout.tabs(
Chart: chart,
Table: Explorer.DataFrame.new(data)
),
to: origin
)
end
display_error = fn frame, message, to: origin ->
Kino.Frame.render(
frame,
Kino.Markdown.new("<p style='font-style:italic; color:red'>#{message}</p"),
to: origin
)
end
```
```elixir
form =
Kino.Control.form(
[
repo_name: Kino.Input.text("Github full repo name", default: "livebook-dev/livebook")
],
submit: "Generate Chart"
)
frame = Kino.Frame.new()
Kino.listen(form, fn %{data: %{repo_name: repo_name}, origin: origin} ->
Kino.Frame.clear(frame, to: origin)
if repo_name == "" or repo_name == nil do
display_error.(frame, "repo can't be blank", to: origin)
else
Kino.Frame.append(frame, "Collecting stars from Github API...", to: origin)
case Github.get_star_dates(repo_name) do
{:ok, star_dates} ->
data = process_data.(star_dates)
display_data.(frame, %{data: data, repo_name: repo_name}, to: origin)
{:error, "Not Found"} ->
display_error.(frame, "repo not found", to: origin)
end
end
end)
Kino.Layout.grid([form, frame], boxed: true)
```