lib/openagents/repositories/repository.ex

main at 58e6347eeb72 · 6 KB

defmodule OpenAgents.Repositories.Repository do
  @moduledoc false

  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  @timestamps_opts [type: :utc_datetime_usec]

  @type t :: %__MODULE__{}

  schema "repositories" do
    field :owner, :string
    field :name, :string
    field :owner_key, :string
    field :name_key, :string
    field :visibility, :string, default: "private"
    field :default_branch, :string, default: "main"
    field :protected_branches, {:array, :string}, default: []
    field :description, :string
    field :pull_requests_enabled, :boolean, default: true
    field :lifecycle_state, :string, default: "provisioning"
    field :provisioning_kind, :string, default: "empty"
    field :provision_error_code, :string
    field :storage_key, :string
    field :ready_at, :utc_datetime_usec
    field :upstream_url, :string
    field :upstream_license, :string

    belongs_to :namespace, OpenAgents.Repositories.Namespace
    belongs_to :created_by_user, OpenAgents.Accounts.User
    has_many :memberships, OpenAgents.Repositories.Membership
    has_one :repository_import, OpenAgents.Repositories.RepositoryImport
    has_one :provisioning_outbox, OpenAgents.Repositories.ProvisioningOutbox

    timestamps()
  end

  def changeset(repository, attrs) do
    repository
    |> cast(attrs, [
      :namespace_id,
      :owner,
      :name,
      :visibility,
      :default_branch,
      :description,
      :pull_requests_enabled,
      :lifecycle_state,
      :provisioning_kind,
      :provision_error_code,
      :storage_key,
      :ready_at
    ])
    |> validate_required([
      :namespace_id,
      :owner,
      :name,
      :visibility,
      :default_branch,
      :lifecycle_state,
      :provisioning_kind,
      :storage_key
    ])
    |> validate_inclusion(:visibility, ~w(public private))
    |> validate_inclusion(:lifecycle_state, ~w(provisioning ready failed))
    |> validate_inclusion(:provisioning_kind, ~w(empty github_import))
    |> validate_length(:owner, min: 1, max: 100)
    |> validate_length(:name, min: 1, max: 64)
    |> validate_format(:name, ~r/\A[a-z0-9](?:[a-z0-9_-]|\.(?=[a-z0-9])){0,63}\z/)
    |> validate_length(:description, max: 350)
    |> validate_length(:default_branch, min: 1, max: 255)
    |> validate_branch_name()
    |> validate_length(:provision_error_code, max: 80)
    |> normalize_path()
    |> unique_constraint([:namespace_id, :name_key])
    |> unique_constraint(:storage_key)
    |> foreign_key_constraint(:namespace_id)
    |> foreign_key_constraint(:created_by_user_id)
    |> check_constraint(:visibility, name: :repositories_visibility_check)
    |> check_constraint(:owner_key, name: :repositories_normalized_path_check)
    |> check_constraint(:lifecycle_state, name: :repositories_lifecycle_state_check)
    |> check_constraint(:provisioning_kind, name: :repositories_provisioning_kind_check)
    |> check_constraint(:ready_at, name: :repositories_ready_state_check)
    |> check_constraint(:upstream_url, name: :repositories_upstream_mirror_check)
  end

  def creation_changeset(repository, attrs, namespace, created_by_user_id, provisioning_kind) do
    storage_key = Ecto.UUID.generate()

    attrs =
      attrs
      |> Map.new()
      |> normalize_creation_name()
      |> Map.put(:namespace_id, namespace.id)
      |> Map.put(:owner, namespace.slug)
      |> Map.put_new(:visibility, "private")
      |> Map.put_new(:default_branch, "main")
      |> Map.put(:lifecycle_state, "provisioning")
      |> Map.put(:provisioning_kind, provisioning_kind)
      |> Map.put(:storage_key, storage_key)

    repository
    |> changeset(attrs)
    |> put_change(:created_by_user_id, created_by_user_id)
  end

  @doc """
  Build an upstream mirror: a repository whose content comes from a public
  source this forge does not own.

  The upstream fields are deliberately absent from `changeset/2`'s `cast`
  list and are written only here, through `put_change/3`. Caller-supplied
  attributes therefore cannot make a repository claim an upstream, and the
  ordinary import path cannot produce a mirror however its attributes are
  shaped. A mirror exists only because a caller asked this function for one.

  `license` is the upstream's SPDX identifier, or the literal `"none"` when
  the upstream publishes no license. `nil` is not accepted: the database
  constraint pairs the two columns, and a mirror that says nothing about its
  license is exactly the state this refuses to represent.
  """
  def mirror_creation_changeset(
        repository,
        attrs,
        namespace,
        created_by_user_id,
        upstream_url,
        license
      )
      when is_binary(upstream_url) and is_binary(license) do
    repository
    |> creation_changeset(attrs, namespace, created_by_user_id, "github_import")
    |> put_change(:upstream_url, upstream_url)
    |> put_change(:upstream_license, license)
    |> validate_length(:upstream_url, min: 12, max: 500)
    |> validate_format(:upstream_url, ~r{\Ahttps://[a-z0-9.-]+/[^\s]+\z})
    |> validate_length(:upstream_license, min: 1, max: 60)
  end

  @doc "Whether this repository is an upstream mirror rather than an owned repository."
  def mirror?(%__MODULE__{upstream_url: url}), do: is_binary(url)

  defp normalize_creation_name(attrs) do
    case Map.get(attrs, :name, Map.get(attrs, "name")) do
      name when is_binary(name) ->
        attrs
        |> Map.delete("name")
        |> Map.put(:name, String.downcase(name))

      _missing ->
        attrs
    end
  end

  defp normalize_path(changeset) do
    owner = get_field(changeset, :owner)
    name = get_field(changeset, :name)

    changeset
    |> maybe_put_key(:owner_key, owner)
    |> maybe_put_key(:name_key, name)
  end

  defp maybe_put_key(changeset, _field, value) when not is_binary(value), do: changeset

  defp maybe_put_key(changeset, field, value) do
    put_change(changeset, field, String.downcase(value))
  end

  defp validate_branch_name(changeset) do
    validate_change(changeset, :default_branch, fn :default_branch, branch ->
      invalid? =
        String.starts_with?(branch, ["-", "."]) or
          String.ends_with?(branch, [".", "/", ".lock"]) or
          String.contains?(branch, ["..", "@{", "\\", " ", "~", "^", ":", "?", "*", "["])

      if invalid?, do: [default_branch: "is not a valid Git ref name"], else: []
    end)
  end
end