lib/openagents/milestones.ex

58e6347eeb72 · 6 KB

defmodule OpenAgents.Milestones do
  @moduledoc """
  The Milestones context.
  """

  import Ecto.Query, warn: false
  alias OpenAgents.Accounts.User
  alias OpenAgents.Analytics
  alias OpenAgents.Issues.Issue
  alias OpenAgents.Repo
  alias OpenAgents.Repositories.Repository

  alias OpenAgents.Milestones.Milestone

  @doc """
  Subscribes the caller to one repository's milestones.

  The message is `{:milestones_changed, repository_id}` and carries nothing
  else, so a subscriber re-reads through its own visibility and authorization
  predicates.

  It says the milestone set moved, not that the issue counts beside it did.
  Those hang off issue rows and arrive on the issue topic, so a page showing
  both subscribes to both.
  """
  def subscribe_milestones(repository_id) when is_binary(repository_id),
    do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, milestones_topic(repository_id))

  @doc """
  Announces that one repository's milestones moved.

  Called after the owning write commits, never inside it: a subscriber re-reads
  the moment it hears, and an announcement from inside an open transaction
  hands it the repository as it was.
  """
  def broadcast_milestones(repository_id) when is_binary(repository_id) do
    Phoenix.PubSub.broadcast(
      OpenAgents.PubSub,
      milestones_topic(repository_id),
      {:milestones_changed, repository_id}
    )
  end

  defp milestones_topic(repository_id), do: "milestones:" <> repository_id

  @doc """
  Returns the list of milestones.

  ## Examples

      iex> list_milestones()
      [%Milestone{}, ...]

  """
  def list_milestones(%Repository{id: repository_id}) do
    Milestone
    |> where(repository_id: ^repository_id)
    |> order_by(asc: :number)
    |> with_issue_counts()
    |> Repo.all()
  end

  @doc """
  Gets a single milestone.

  Raises `Ecto.NoResultsError` if the Milestone does not exist.

  ## Examples

      iex> get_milestone!(123)
      %Milestone{}

      iex> get_milestone!(456)
      ** (Ecto.NoResultsError)

  """
  def get_milestone!(%Repository{id: repository_id}, id) do
    Milestone
    |> where(id: ^id, repository_id: ^repository_id)
    |> with_issue_counts()
    |> Repo.one!()
  end

  def get_milestone_by_number!(%Repository{id: repository_id}, number) when is_integer(number) do
    Milestone
    |> where(repository_id: ^repository_id, number: ^number)
    |> with_issue_counts()
    |> Repo.one!()
  end

  @doc """
  Creates a milestone.

  ## Examples

      iex> create_milestone(%{field: value})
      {:ok, %Milestone{}}

      iex> create_milestone(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def create_milestone(%Repository{} = repository, attrs, actor \\ nil)
      when is_nil(actor) or is_struct(actor, User) do
    normalized = for {k, v} <- attrs, into: %{}, do: {to_string(k), v}
    explicit_number? = Map.has_key?(normalized, "number")

    create_milestone_with_number(repository, normalized, explicit_number?, actor, 20)
  end

  defp create_milestone_with_number(
         repository,
         normalized,
         explicit_number?,
         actor,
         attempts_remaining
       ) do
    number = next_milestone_number(repository.id)

    %Milestone{}
    |> Milestone.changeset(
      normalized
      |> Map.put_new("number", number)
      |> Map.put("repository_id", repository.id)
    )
    |> Repo.insert()
    |> case do
      {:error, changeset} when not explicit_number? and attempts_remaining > 1 ->
        if number_conflict?(changeset) do
          create_milestone_with_number(
            repository,
            normalized,
            explicit_number?,
            actor,
            attempts_remaining - 1
          )
        else
          {:error, changeset}
        end

      {:ok, milestone} ->
        Analytics.capture("milestone_created", actor_distinct_id(actor), %{
          "owner" => repository.owner,
          "repo" => repository.name
        })

        broadcast_milestones(repository.id)
        {:ok, milestone}

      result ->
        result
    end
  end

  defp actor_distinct_id(nil), do: Analytics.system_distinct_id("api")
  defp actor_distinct_id(%User{} = actor), do: Analytics.distinct_id(actor)

  defp next_milestone_number(repository_id) do
    case Repo.aggregate(
           from(m in Milestone, where: m.repository_id == ^repository_id),
           :max,
           :number
         ) do
      nil -> 1
      n -> n + 1
    end
  end

  defp number_conflict?(changeset) do
    Enum.any?(changeset.errors, fn {_field, {_message, options}} ->
      options[:constraint_name] == "milestones_repository_id_number_index"
    end)
  end

  @doc """
  Updates a milestone.

  ## Examples

      iex> update_milestone(milestone, %{field: new_value})
      {:ok, %Milestone{}}

      iex> update_milestone(milestone, %{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def update_milestone(%Milestone{} = milestone, attrs) do
    attrs = Map.drop(attrs, [:repository_id, "repository_id", :number, "number"])

    case milestone |> Milestone.changeset(attrs) |> Repo.update() do
      {:ok, updated} ->
        broadcast_milestones(updated.repository_id)

        {:ok,
         get_milestone!(
           %Repository{id: updated.repository_id},
           updated.id
         )}

      result ->
        result
    end
  end

  @doc """
  Deletes a milestone.

  ## Examples

      iex> delete_milestone(milestone)
      {:ok, %Milestone{}}

      iex> delete_milestone(milestone)
      {:error, %Ecto.Changeset{}}

  """
  def delete_milestone(%Milestone{} = milestone) do
    case Repo.delete(milestone) do
      {:ok, deleted} ->
        broadcast_milestones(deleted.repository_id)
        {:ok, deleted}

      result ->
        result
    end
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking milestone changes.

  ## Examples

      iex> change_milestone(milestone)
      %Ecto.Changeset{data: %Milestone{}}

  """
  def change_milestone(%Milestone{} = milestone, attrs \\ %{}) do
    Milestone.changeset(milestone, attrs)
  end

  defp with_issue_counts(query) do
    from milestone in query,
      left_join: issue in Issue,
      on:
        issue.milestone_id == milestone.id and
          issue.repository_id == milestone.repository_id,
      group_by: milestone.id,
      select_merge: %{
        open_issues: filter(count(issue.id), issue.state == "open"),
        closed_issues: filter(count(issue.id), issue.state == "closed")
      }
  end
end