lib/openagents/repositories/github_projection.ex

main at 58e6347eeb72 · 7 KB

defmodule OpenAgents.Repositories.GitHubProjection do
  @moduledoc "Projects retained GitHub authority into hosted namespaces and import snapshots."

  alias OpenAgents.{Accounts, GitHub, GitHubOAuth, Repositories}
  alias OpenAgents.Accounts.User
  alias OpenAgents.Repositories.Namespace

  @maximum_organization_pages 10

  def available_namespaces(%User{} = user) do
    with {:ok, user_namespace} <- Repositories.ensure_user_namespace(user),
         {:ok, token} <- retained_token(user),
         {:ok, organizations} <- collect_admin_organizations(token, 1, []) do
      namespaces =
        Enum.map(organizations, fn organization ->
          {:ok, namespace} =
            Repositories.upsert_github_namespace(%{
              provider_account_id: organization["id"],
              provider_node_id: organization["node_id"],
              slug: organization["login"],
              kind: "organization"
            })

          namespace
        end)

      {:ok, [user_namespace | namespaces]}
    else
      {:error, :github_token_missing} -> {:error, :github_connection_required}
      {:error, reason} -> {:error, reason}
    end
  end

  def import_candidates(%User{} = user, page \\ 1) when page in 1..1_000 do
    with {:ok, namespaces} <- available_namespaces(user),
         {:ok, token} <- retained_token(user),
         {:ok, repositories} <- GitHub.list_repository_page(token, page: page, per_page: 50) do
      namespace_ids = MapSet.new(namespaces, & &1.provider_account_id)

      items =
        Enum.filter(repositories["items"], fn repository ->
          repository["readable"] and not repository["archived"] and
            MapSet.member?(namespace_ids, repository["owner"]["id"])
        end)

      {:ok, %{repositories | "items" => items}}
    else
      {:error, :github_token_missing} -> {:error, :github_connection_required}
      {:error, reason} -> {:error, reason}
    end
  end

  @doc """
  Resolves one named owner to a namespace the caller may create repositories in.

  An owner is either a personal namespace or an organization, and the name
  alone does not say which. Deciding here is what lets a client pass along the
  owner it was given instead of guessing at the kind and picking a route on
  the guess.

  The caller's own login resolves without asking GitHub: the token already
  authenticates that identity, so a personal repository does not depend on a
  retained GitHub token the way an organization repository does. Another
  person's namespace is refused outright rather than asked about, because
  GitHub cannot answer "is this login an organization you administer" for a
  login that is a person.
  """
  def authorized_namespace(%User{} = user, requested_slug) when is_binary(requested_slug) do
    if own_login?(user, requested_slug) do
      Repositories.ensure_user_namespace(user)
    else
      user_id = user.id

      case Repositories.get_namespace_by_slug(requested_slug) do
        %Namespace{kind: "user", owner_user_id: ^user_id} = namespace ->
          {:ok, namespace}

        %Namespace{kind: "user"} ->
          {:error, :namespace_not_allowed}

        _absent_or_organization ->
          authorized_organization(user, requested_slug)
      end
    end
  end

  defp own_login?(%User{github_login: login}, requested_slug) when is_binary(login),
    do: String.downcase(login) == String.downcase(requested_slug)

  defp own_login?(_user, _requested_slug), do: false

  def authorized_organization(%User{} = user, requested_slug) when is_binary(requested_slug) do
    with {:ok, token} <- retained_token(user),
         {:ok, membership} <- find_organization_membership(token, requested_slug, 1),
         "admin" <- membership["role"] || {:error, :namespace_not_allowed},
         organization = membership["organization"],
         {:ok, namespace} <-
           Repositories.upsert_github_namespace(%{
             provider_account_id: organization["id"],
             provider_node_id: organization["node_id"],
             slug: organization["login"],
             kind: "organization"
           }) do
      {:ok, namespace}
    else
      {:error, reason} -> {:error, reason}
      _not_admin -> {:error, :namespace_not_allowed}
    end
  end

  def import_source(%User{} = user, full_name) when is_binary(full_name) do
    with {:ok, token} <- retained_token(user),
         {:ok, repository} <- GitHub.get_import_source(token, full_name),
         {:ok, references} <- GitHub.list_references(token, full_name),
         {:ok, lfs_warning?} <- lfs_warning(token, repository, references) do
      refs = Map.new(references["refs"], &{&1["name"], &1["sha"]})
      default_branch = repository["default_branch"]

      {:ok,
       %{
         provider: "github",
         source_repository_id: repository["id"],
         source_owner_id: repository["owner"]["id"],
         source_full_name: repository["full_name"],
         source_default_branch: default_branch,
         source_ref_digest: references["digest"],
         source_head_sha: refs["refs/heads/#{default_branch}"],
         source_refs: refs,
         source_uses_lfs: lfs_warning?,
         source_public: repository["private"] == false,
         source_license: repository["license"]
       }, repository}
    else
      {:error, :github_token_missing} -> {:error, :github_connection_required}
      {:error, :github_not_found} -> {:error, :source_repository_not_accessible}
      {:error, :github_permission_denied} -> {:error, :source_repository_not_accessible}
      {:error, reason} -> {:error, reason}
    end
  end

  defp retained_token(%User{} = user) do
    if user.github_token_scopes == GitHubOAuth.required_scopes() do
      Accounts.github_token(user)
    else
      {:error, :github_scope_required}
    end
  end

  defp find_organization_membership(_token, _requested_slug, page)
       when page > @maximum_organization_pages,
       do: {:error, :namespace_not_allowed}

  defp find_organization_membership(token, requested_slug, page) do
    requested_key = String.downcase(requested_slug)

    with {:ok, result} <- GitHub.list_active_organization_memberships(token, page: page) do
      case Enum.find(result["items"], fn membership ->
             String.downcase(membership["organization"]["login"]) == requested_key
           end) do
        nil ->
          if result["has_next_page"],
            do: find_organization_membership(token, requested_slug, page + 1),
            else: {:error, :namespace_not_allowed}

        membership ->
          {:ok, membership}
      end
    end
  end

  defp collect_admin_organizations(_token, page, _acc)
       when page > @maximum_organization_pages,
       do: {:error, :github_pagination_limit_exceeded}

  defp collect_admin_organizations(token, page, acc) do
    with {:ok, result} <- GitHub.list_active_organization_memberships(token, page: page) do
      current =
        result["items"]
        |> Enum.filter(&(&1["role"] == "admin"))
        |> Enum.map(& &1["organization"])

      next_acc = acc ++ current

      if result["has_next_page"],
        do: collect_admin_organizations(token, page + 1, next_acc),
        else: {:ok, next_acc}
    end
  end

  defp lfs_warning(_token, _repository, %{"refs" => []}), do: {:ok, false}

  defp lfs_warning(token, repository, _references) do
    case GitHub.lfs_warning_inputs(token, repository["full_name"], repository["default_branch"]) do
      {:ok, inputs} -> {:ok, inputs["warning_recommended"]}
      {:error, reason} -> {:error, reason}
    end
  end
end