4.1 KiB · text 5af55d9
defmodule GitGud.Federation.Outbound do
@moduledoc """
Send local content to remote AP actors.
Currently exposes `offer_pull_request/4` so a local user can propose
a merge against a remote repository identified by its AP actor URL.
This is the outbound side of the federated-PR flow whose inbound
counterpart lives in `GitGud.Federation.Workers.ProcessInbox`.
There is no local PR row for outbound offers — the target lives on
another instance and owns the canonical PR state. We record the
`Offer` in `fed_activities` so the UI can list "PRs I've sent out"
later and so any inbound `Accept` / `Reject` / `Update` referencing
the original Offer URL can be matched back.
"""
alias GitGud.Accounts.User
alias GitGud.Federation
alias GitGud.Federation.Activity
alias GitGud.Federation.ActivityBuilders
alias GitGud.Federation.Actor
alias GitGud.Federation.RemoteActors
alias GitGud.Federation.Workers.DeliverOutbound
alias GitGud.Repo
alias GitGud.Repositories.Repository
@type offer_params :: %{
target_repo_url: String.t(),
source_repo: Repository.t(),
source_branch: String.t(),
target_branch: String.t(),
head_hex: String.t(),
title: String.t(),
body: String.t() | nil
}
@doc """
Build, persist, and queue delivery of an `Offer(Ticket)` to a remote
repository's inbox.
Returns `{:ok, activity}` on success. The activity row is the local
record; `DeliverOutbound` does the signed HTTP POST.
"""
@spec offer_pull_request(User.t(), Repository.t(), offer_params(), keyword()) ::
{:ok, Activity.t()} | {:error, term()}
def offer_pull_request(%User{} = author, %Repository{} = source_repo, params, opts \\ []) do
head_hex = Map.fetch!(params, :head_hex)
with :ok <- validate_hex(head_hex),
{:ok, target_actor} <- resolve_target_actor(Map.fetch!(params, :target_repo_url)),
:ok <- ensure_repo_actor_type(target_actor),
{:ok, author_actor} <- Federation.ensure_local_actor(author),
{:ok, _source_actor} <- Federation.ensure_local_actor(source_repo) do
clone_url = Keyword.get(opts, :source_clone_url) || default_clone_url(source_repo)
doc =
ActivityBuilders.offer_pull_request(author_actor, target_actor, %{
title: Map.fetch!(params, :title),
body: Map.get(params, :body),
source_branch: Map.fetch!(params, :source_branch),
target_branch: Map.fetch!(params, :target_branch),
head_hex: head_hex,
source_clone_url: clone_url
})
activity_attrs = %{
activity_url: doc["id"],
type: "Offer",
actor_id: author_actor.id,
target_actor_id: target_actor.id,
raw: doc,
is_local: true,
published_at: DateTime.utc_now(:second)
}
case %Activity{} |> Activity.changeset(activity_attrs) |> Repo.insert() do
{:ok, activity} ->
enqueue_delivery(activity, target_actor)
{:ok, activity}
err ->
err
end
end
end
defp validate_hex(<<_::binary-size(40)>>), do: :ok
defp validate_hex(_), do: {:error, :invalid_head_hex}
defp resolve_target_actor(url) when is_binary(url) do
case RemoteActors.fetch(url) do
{:ok, %Actor{} = actor} -> {:ok, actor}
{:error, _} = err -> err
_ -> {:error, :unresolvable_target}
end
end
# Only ForgeFed `Repository` actors can host PRs. Person/Group inboxes
# would silently no-op the Offer on the receiving side.
defp ensure_repo_actor_type(%Actor{actor_type: "Repository"}), do: :ok
defp ensure_repo_actor_type(%Actor{}), do: {:error, :target_not_a_repository}
defp default_clone_url(%Repository{} = repo) do
Federation.repo_actor_url(repo) <> ".git"
end
defp enqueue_delivery(activity, target_actor) do
inbox = target_actor.shared_inbox_url || target_actor.inbox_url
if is_binary(inbox) and inbox != "" do
{:ok, _job} = DeliverOutbound.new_job(activity.id, inbox) |> Oban.insert()
:ok
else
{:error, :no_inbox_on_target}
end
end
end