Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

CI Green Streak

ci_green_streak.livemd

CI Green Streak

Mix.install([
  {:plug, "~> 1.16"},
  {:jason, "~> 1.4"},
  {:kino, "~> 0.13.1"},
  {:req, "~> 0.5.0"}
])

Storage

defmodule Storage do
  def get!(dets_table, key) do
    [{^key, value}] = :dets.lookup(dets_table, key)
    value
  end

  def set!(dets_table, key, value) do
    :ok = :dets.insert(dets_table, {key, value})
    value
  end
end

UI

defmodule UiHelpers do
  @doc ~S"""
  ## Examples

      iex> UiHelpers.seconds_to_words(0)
      "Unknown time"

      iex> UiHelpers.seconds_to_words(60)
      "60 Second(s)"

      iex> UiHelpers.seconds_to_words(89_600)
      "1 Day(s)"
  """
  def seconds_to_words(0), do: "Unknown time"
  def seconds_to_words(seconds) when seconds < 0, do: "0 Seconds"
  def seconds_to_words(seconds) when seconds <= 60, do: "#{seconds} Second(s)"
  def seconds_to_words(seconds) when seconds <= 3_600, do: "#{floor(seconds / 60)} Minute(s)"
  def seconds_to_words(seconds) when seconds <= 86_400, do: "#{floor(seconds / 3_600)} Hour(s)"
  def seconds_to_words(seconds), do: "#{floor(seconds / 86_400)} Day(s)"
end
defmodule BuildStreakKino do
  def new(build_streak) do
    Kino.Layout.grid([
      current_streak(build_streak),
      record_streak(build_streak)
    ])
  end

  def record_streak(build_streak) do
    Kino.Markdown.new("""
    **Our record is #{UiHelpers.seconds_to_words(build_streak.record)}**
    """)
  end

  def current_streak(build_streak) do
    Kino.HTML.new("""
    

Without a Red Build

function formatDuration(seconds) { const days = Math.floor(seconds / (24 * 60 * 60)); seconds %= 24 * 60 * 60; const hours = Math.floor(seconds / (60 * 60)); seconds %= 60 * 60; const minutes = Math.floor(seconds / 60); seconds %= 60; const parts = []; if (days > 0) parts.push(`${days} day${days !== 1 ? 's' : ''}`); if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`); if (minutes > 0) parts.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`); if (seconds > 0 || parts.length === 0) parts.push(`${seconds} second${seconds !== 1 ? 's' : ''}`); if (parts.length > 1) { const lastPart = parts.pop(); return parts.join(', ') + ', and ' + lastPart; } else { return parts[0]; } } function updateDuration() { const spanElement = document.getElementById('current-streak'); const lastRedBuild = new Date(spanElement.getAttribute('data-last-red-build')); const now = new Date(); const elapsedSeconds = Math.floor((now - lastRedBuild) / 1000); const formattedDuration = formatDuration(elapsedSeconds); spanElement.textContent = formattedDuration; } setInterval(updateDuration, 1000); updateDuration(); """
) end end

Slack notifier

defmodule SlackNotifier do
  def record_streak(build_streak) do
    # The Slack notification feature is optional. To use it,
    # create a Livebook secret with the name "SLACK_TOKEN"
    # and the value should be the token of your Slack app.
    #
    # To create a Slack app for you and get a token, follow
    # https://api.slack.com/tutorials/tracks/getting-a-token
    with {:ok, slack_token} <- System.fetch_env("LB_SLACK_TOKEN") do
      req =
        Req.new(
          base_url: "https://slack.com/api",
          auth: {:bearer, slack_token}
        )

      message =
        "New build streak record: #{UiHelpers.seconds_to_words(build_streak.record)}"

      response =
        Req.post!(req,
          url: "/chat.postMessage",
          json: %{channel: "#notifications", text: message}
        )

      case response.body do
        %{"ok" => true} -> :ok
        %{"ok" => false, "error" => error} -> {:error, error}
      end
    end
  end
end

Data structures

defmodule Build do
  defstruct [:conclusion, :created_at, :head_branch]

  def new(attrs) do
    attrs =
      Enum.reduce(attrs, %{}, fn {key, value}, acc ->
        atom_key = String.to_existing_atom(key)
        value = cast(key, value)
        Map.put(acc, atom_key, value)
      end)

    struct(__MODULE__, attrs)
  end

  def cast("created_at", value) do
    value
    |> DateTime.from_iso8601()
    |> then(fn {:ok, datetime, 0} -> datetime end)
  end

  def cast(_key, value), do: value
end
defmodule BuildStreak do
  defstruct [:record, :last_red_build]

  def get!(storage) do
    Storage.get!(storage, :build_streak)
  end

  def save!(build_streak, storage) do
    Storage.set!(storage, :build_streak, build_streak)
  end

  def update_from_build(build_streak, storage, %Build{} = build) do
    if build.head_branch == "main" do
      handle_build_conclusion(build_streak, build, storage)
    else
      build_streak
    end
  end

  defp handle_build_conclusion(build_streak, %Build{conclusion: "failure"} = build, storage) do
    build_streak = %{build_streak | last_red_build: build.created_at}
    BuildStreak.save!(build_streak, storage)
  end

  defp handle_build_conclusion(build_streak, %Build{conclusion: "success"} = build, storage) do
    new_streak = DateTime.diff(build.created_at, build_streak.last_red_build)

    if new_streak > build_streak.record do
      build_streak = %{build_streak | record: new_streak}

      build_streak
      |> BuildStreak.save!(storage)
      |> SlackNotifier.record_streak()

      build_streak
    else
      build_streak
    end
  end
end

Server

defmodule BuildStreakServer do
  use GenServer

  def start_link(state) do
    GenServer.start_link(__MODULE__, state, name: __MODULE__)
  end

  def update_from_build(build) do
    GenServer.cast(__MODULE__, {:update_from_build, build})
  end

  @impl true
  def init(state) do
    build_streak = BuildStreak.get!(state.storage)
    state = Map.put(state, :build_streak, build_streak)

    {button, state} = Map.pop!(state, :reset_button)
    Kino.Control.subscribe(button, :reset_button_clicked)

    {:ok, state |> render()}
  end

  @impl true
  def handle_info({:reset_button_clicked, _}, state) do
    build_streak = %BuildStreak{record: 0, last_red_build: DateTime.utc_now()}
    BuildStreak.save!(build_streak, state.storage)

    {:noreply, %{state | build_streak: build_streak} |> render()}
  end

  @impl true
  def handle_cast({:update_from_build, build}, state) do
    build_streak = BuildStreak.update_from_build(state.build_streak, state.storage, build)

    {:noreply, %{state | build_streak: build_streak} |> render()}
  end

  defp render(state) do
    build_streak_kino = BuildStreakKino.new(state.build_streak)
    Kino.Frame.render(state.frame, build_streak_kino)
    state
  end
end

API

defmodule ApiRouter do
  use Plug.Router

  plug(:match)
  plug(Plug.Logger, log: :debug)
  plug(Plug.Parsers, parsers: [:json], json_decoder: Jason)
  plug(:dispatch)

  post "/webhook" do
    update_streak(conn.body_params)

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(200, ~s({"message": "ok"}))
  end

  match _ do
    conn
    |> put_resp_content_type("application/json")
    |> send_resp(404, ~s({"message": "not found"}))
  end

  defp update_streak(webhook_payload) do
    get_in(webhook_payload["check_suite"])
    |> Build.new()
    |> BuildStreakServer.update_from_build()
  end
end

Main

defmodule App do
  def start() do
    dets_table = setup_dets_table()
    setup_initial_state(dets_table)
    do_start(dets_table)
  end

  defp setup_dets_table() do
    cache_dir = :filename.basedir(:user_cache, "lb_app_ci_build_streak")
    File.mkdir_p!(cache_dir)
    dets_table_path = Path.join(cache_dir, "storage.dets")

    with {:ok, dets_table} <-
           :dets.open_file(:storage, type: :set, file: String.to_charlist(dets_table_path)) do
      dets_table
    else
      {:error, reason} -> raise "Failed to open DETS table: #{inspect(reason)}"
    end
  end

  defp setup_initial_state(dets_table) do
    case :dets.lookup(dets_table, :build_streak) do
      [] ->
        %BuildStreak{record: 0, last_red_build: DateTime.utc_now()}
        |> BuildStreak.save!(dets_table)

      _ ->
        :ok
    end
  end

  defp do_start(dets_table) do
    Kino.Proxy.listen(ApiRouter)

    build_streak_frame = Kino.Frame.new()
    button = Kino.Control.button("Reset counters")

    Kino.start_child!({
      BuildStreakServer,
      %{frame: build_streak_frame, reset_button: button, storage: dets_table}
    })

    Kino.Layout.grid([
      build_streak_frame,
      button
    ])
  end
end

App.start()